Browse Source

test(replay): Use new mocks to test ReplayReader (#50271)

Ryan Albrecht 1 year ago
parent
commit
205ce4c4f5

+ 4 - 0
fixtures/js-stubs/replay.ts

@@ -1,9 +1,13 @@
+import * as Helpers from './replay/helpers';
 import * as BreadcrumbFrameData from './replay/replayBreadcrumbFrameData';
 import * as ReplayFrameEvents from './replay/replayFrameEvents';
 import * as ReplaySpanFrameData from './replay/replaySpanFrameData';
+import * as RRweb from './replay/rrweb';
 
 export const Replay = {
   ...BreadcrumbFrameData,
+  ...Helpers,
   ...ReplayFrameEvents,
   ...ReplaySpanFrameData,
+  ...RRweb,
 };

+ 85 - 0
fixtures/js-stubs/replay/helpers.ts

@@ -0,0 +1,85 @@
+import * as BreadcrumbFrameData from './replayBreadcrumbFrameData';
+import * as ReplayFrameEvents from './replayFrameEvents';
+import * as ReplaySpanFrameData from './replaySpanFrameData';
+
+export function ConsoleEvent({timestamp, message}: {timestamp: Date; message?: string}) {
+  return ReplayFrameEvents.BreadcrumbFrameEvent({
+    timestamp,
+    data: {
+      payload: BreadcrumbFrameData.ConsoleFrame({
+        timestamp,
+        message: message ?? 'Hello World',
+      }),
+    },
+  });
+}
+
+export function ClickEvent({timestamp}: {timestamp: Date}) {
+  return ReplayFrameEvents.BreadcrumbFrameEvent({
+    timestamp,
+    data: {
+      payload: BreadcrumbFrameData.ClickFrame({
+        timestamp,
+        message: 'nav[aria-label="Primary Navigation"] > div > a#sidebar-item-projects',
+        data: {
+          nodeId: 42,
+        },
+      }),
+    },
+  });
+}
+
+export function NavigateEvent({
+  startTimestamp,
+  endTimestamp,
+}: {
+  endTimestamp: Date;
+  startTimestamp: Date;
+}) {
+  const duration = endTimestamp.getTime() - startTimestamp.getTime(); // in MS
+
+  return ReplayFrameEvents.SpanFrameEvent({
+    timestamp: startTimestamp,
+    data: {
+      payload: ReplaySpanFrameData.NavigationFrame({
+        op: 'navigation.navigate',
+        startTimestamp,
+        endTimestamp,
+        description: '',
+        data: {
+          size: 1149,
+          decodedBodySize: 1712,
+          encodedBodySize: 849,
+          duration,
+          domInteractive: duration - 200,
+          domContentLoadedEventStart: duration - 50,
+          domContentLoadedEventEnd: duration - 48,
+          loadEventStart: duration, // real value would be approx the same
+          loadEventEnd: duration, // real value would be approx the same
+          domComplete: duration, // real value would be approx the same
+          redirectCount: 0,
+        },
+      }),
+    },
+  });
+}
+
+export function MemoryEvent({
+  startTimestamp,
+  endTimestamp,
+}: {
+  endTimestamp: Date;
+  startTimestamp: Date;
+}) {
+  return ReplayFrameEvents.SpanFrameEvent({
+    timestamp: startTimestamp,
+    data: {
+      payload: ReplaySpanFrameData.MemoryFrame({
+        op: 'memory',
+        startTimestamp,
+        endTimestamp,
+        description: '',
+      }),
+    },
+  });
+}

+ 116 - 0
fixtures/js-stubs/replay/rrweb.ts

@@ -0,0 +1,116 @@
+import type {fullSnapshotEvent, serializedNodeWithId} from 'sentry/utils/replays/types';
+import {EventType, NodeType} from 'sentry/utils/replays/types';
+
+interface FullSnapshotEvent extends fullSnapshotEvent {
+  timestamp: number;
+}
+
+const nextRRWebId = (function () {
+  let __rrwebID = 0;
+  return () => ++__rrwebID;
+})();
+
+export function RRWebInitFrameEvents({
+  height = 600,
+  href = 'http://localhost/',
+  timestamp,
+  width = 800,
+}: {
+  height: number;
+  href: string;
+  timestamp: Date;
+  width: number;
+}) {
+  return [
+    {
+      type: EventType.DomContentLoaded,
+      timestamp: timestamp.getTime(), // rrweb timestamps are in ms
+    },
+    {
+      type: EventType.Load,
+      timestamp: timestamp.getTime(), // rrweb timestamps are in ms
+    },
+    {
+      type: EventType.Meta,
+      data: {href, width, height},
+      timestamp: timestamp.getTime(), // rrweb timestamps are in ms
+    },
+  ];
+}
+
+export function RRWebFullSnapshotFrameEvent({
+  timestamp,
+  childNodes = [],
+}: {
+  timestamp: Date;
+  childNodes?: serializedNodeWithId[];
+}): FullSnapshotEvent {
+  return {
+    type: EventType.FullSnapshot,
+    timestamp: timestamp.getTime(),
+    data: {
+      initialOffset: {top: 0, left: 0},
+      node: {
+        type: NodeType.Document,
+        id: 0,
+        childNodes: [
+          RRWebDOMFrame({
+            tagName: 'body',
+            attributes: {
+              style:
+                'margin:0; font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu;',
+            },
+            childNodes,
+          }),
+        ],
+      },
+    },
+  };
+}
+
+export function RRWebDOMFrame({
+  id,
+  tagName,
+  attributes,
+  childNodes,
+  textContent,
+}: {
+  attributes?: Record<string, string>;
+  childNodes?: serializedNodeWithId[];
+  id?: number;
+  tagName?: string;
+  textContent?: string;
+}): serializedNodeWithId {
+  id = id ?? nextRRWebId();
+  if (tagName) {
+    return {
+      type: NodeType.Element,
+      id,
+      tagName,
+      attributes: attributes ?? {},
+      childNodes: childNodes ?? [],
+    };
+  }
+  return {
+    type: NodeType.Text,
+    id,
+    textContent: textContent ?? '',
+  };
+}
+
+export function RRWebHelloWorldFrame() {
+  return RRWebDOMFrame({
+    tagName: 'div',
+    childNodes: [
+      RRWebDOMFrame({
+        tagName: 'h1',
+        attributes: {style: 'text-align: center;'},
+        childNodes: [
+          RRWebDOMFrame({
+            textContent: 'Hello World',
+          }),
+        ],
+      }),
+    ],
+  });
+}

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

@@ -200,6 +200,8 @@ export function breadcrumbFactory(
       };
     });
 
+  // TODO(replay): The important parts of transformCrumbs should be brought into
+  // here, we're hydrating our data and should have more control over the process.
   const result = transformCrumbs([
     ...(spans.length && !hasPageLoad ? [initBreadcrumb] : []),
     ...rawCrumbsWithTimestamp,

+ 272 - 0
static/app/utils/replays/replayReader.spec.tsx

@@ -0,0 +1,272 @@
+import {EventType} from '@sentry-internal/rrweb';
+
+import {transformCrumbs} from 'sentry/components/events/interfaces/breadcrumbs/utils';
+import {spansFactory} from 'sentry/utils/replays/replayDataUtils';
+import ReplayReader from 'sentry/utils/replays/replayReader';
+
+describe('ReplayReader', () => {
+  const replayRecord = TestStubs.ReplayRecord({});
+
+  it('Should return null if there are missing arguments', () => {
+    const missingAttachments = ReplayReader.factory({
+      attachments: undefined,
+      errors: [],
+      replayRecord,
+    });
+    expect(missingAttachments).toBeNull();
+
+    const missingErrors = ReplayReader.factory({
+      attachments: [],
+      errors: undefined,
+      replayRecord,
+    });
+    expect(missingErrors).toBeNull();
+
+    const missingRecord = ReplayReader.factory({
+      attachments: [],
+      errors: [],
+      replayRecord: undefined,
+    });
+    expect(missingRecord).toBeNull();
+  });
+
+  it('should calculate started_at/finished_at/duration based on first/last events', () => {
+    const minuteZero = new Date('2023-12-25T00:00:00');
+    const minuteTen = new Date('2023-12-25T00:10:00');
+
+    const replay = ReplayReader.factory({
+      attachments: [
+        TestStubs.Replay.ConsoleEvent({timestamp: minuteZero}),
+        TestStubs.Replay.ConsoleEvent({timestamp: minuteTen}),
+      ],
+      errors: [],
+      replayRecord: TestStubs.ReplayRecord({
+        started_at: new Date('2023-12-25T00:01:00'),
+        finished_at: new Date('2023-12-25T00:09:00'),
+        duration: undefined, // will be calculated
+      }),
+    });
+
+    const expectedDuration = 10 * 60 * 1000; // 10 minutes, in ms
+    expect(replay?.getReplay().started_at).toEqual(minuteZero);
+    expect(replay?.getReplay().finished_at).toEqual(minuteTen);
+    expect(replay?.getReplay().duration.asMilliseconds()).toEqual(expectedDuration);
+    expect(replay?.getDurationMs()).toEqual(expectedDuration);
+  });
+
+  it('should make the replayRecord available through a getter method', () => {
+    const replay = ReplayReader.factory({
+      attachments: [],
+      errors: [],
+      replayRecord,
+    });
+
+    expect(replay?.getReplay()).toEqual(replayRecord);
+  });
+
+  describe('attachment splitting', () => {
+    const timestamp = new Date();
+    const firstDiv = TestStubs.Replay.RRWebFullSnapshotFrameEvent({timestamp});
+    const secondDiv = TestStubs.Replay.RRWebFullSnapshotFrameEvent({timestamp});
+    const clickEvent = TestStubs.Replay.ClickEvent({timestamp});
+    const firstMemory = TestStubs.Replay.MemoryEvent({
+      startTimestamp: timestamp,
+      endTimestamp: timestamp,
+    });
+    const secondMemory = TestStubs.Replay.MemoryEvent({
+      startTimestamp: timestamp,
+      endTimestamp: timestamp,
+    });
+    const navigationEvent = TestStubs.Replay.NavigateEvent({
+      startTimestamp: timestamp,
+      endTimestamp: timestamp,
+    });
+    const consoleEvent = TestStubs.Replay.ConsoleEvent({timestamp});
+    const replayEnd = {
+      type: EventType.Custom,
+      timestamp: expect.any(Number), // will be set to the endTimestamp of the last crumb in the test
+      data: {
+        tag: 'replay-end',
+      },
+    };
+    const attachments = [
+      clickEvent,
+      consoleEvent,
+      firstDiv,
+      firstMemory,
+      navigationEvent,
+      secondDiv,
+      secondMemory,
+    ];
+
+    const {
+      startTimestamp,
+      endTimestamp: _2,
+      op: _1,
+      ...payload
+    } = navigationEvent.data.payload;
+    const expectedNav = {
+      ...payload,
+      action: 'navigate',
+      category: 'default',
+      color: 'green300',
+      description: 'Navigation',
+      id: 2,
+      level: 'info',
+      message: '',
+      type: 'navigation',
+      data: {
+        ...payload.data,
+        label: 'Page load',
+        to: '',
+      },
+      timestamp: new Date(startTimestamp * 1000).toISOString(),
+    };
+
+    function patchEvents(events) {
+      return transformCrumbs(
+        events.map(event => ({
+          ...event.data.payload,
+          id: expect.any(Number),
+          timestamp: new Date(event.data.payload.timestamp * 1000).toISOString(),
+        }))
+      );
+    }
+    function patchSpanEvents(events) {
+      return spansFactory(
+        events.map(event => ({
+          ...event.data.payload,
+          id: expect.any(String),
+          endTimestamp: event.data.payload.endTimestamp,
+          startTimestamp: event.data.payload.startTimestamp,
+        }))
+      );
+    }
+
+    it.each([
+      {
+        method: 'getRRWebEvents',
+        expected: [firstDiv, secondDiv, replayEnd],
+      },
+      {
+        method: 'getCrumbsWithRRWebNodes',
+        expected: patchEvents([clickEvent]),
+      },
+      {
+        method: 'getUserActionCrumbs',
+        expected: [...patchEvents([clickEvent]), expectedNav],
+      },
+      {
+        method: 'getConsoleCrumbs',
+        // Need a non-console event in here so the `id` ends up correct,
+        // slice() removes the extra item later
+        expected: patchEvents([clickEvent, consoleEvent]).slice(1),
+      },
+      {
+        method: 'getNonConsoleCrumbs',
+        expected: [...patchEvents([clickEvent]), expectedNav],
+      },
+      {
+        method: 'getNavCrumbs',
+        expected: [expectedNav],
+      },
+      {
+        method: 'getNetworkSpans',
+        expected: patchSpanEvents([navigationEvent]),
+      },
+      {
+        method: 'getMemorySpans',
+        expected: patchSpanEvents([secondMemory, secondMemory]),
+      },
+    ])('Calling $method will filter attachments', ({method, expected}) => {
+      const replay = ReplayReader.factory({
+        attachments,
+        errors: [],
+        replayRecord,
+      });
+
+      const exec = replay?.[method];
+      expect(exec()).toStrictEqual(expected);
+    });
+  });
+
+  it('shoud return the SDK config if there is a RecordingOptions event found', () => {
+    const timestamp = new Date();
+    const optionsFrame = TestStubs.Replay.OptionFrame({});
+
+    const replay = ReplayReader.factory({
+      attachments: [
+        TestStubs.Replay.OptionFrameEvent({
+          timestamp,
+          data: {payload: optionsFrame},
+        }),
+      ],
+      errors: [],
+      replayRecord,
+    });
+
+    expect(replay?.sdkConfig()).toBe(optionsFrame);
+  });
+
+  describe('isNetworkDetailsSetup', () => {
+    it('should have isNetworkDetailsSetup=true if sdkConfig says so', () => {
+      const timestamp = new Date();
+
+      const replay = ReplayReader.factory({
+        attachments: [
+          TestStubs.Replay.OptionFrameEvent({
+            timestamp,
+            data: {
+              payload: TestStubs.Replay.OptionFrame({
+                networkDetailHasUrls: true,
+              }),
+            },
+          }),
+        ],
+        errors: [],
+        replayRecord,
+      });
+
+      expect(replay?.isNetworkDetailsSetup()).toBeTruthy();
+    });
+
+    it.each([
+      {
+        data: {
+          method: 'GET',
+          request: {headers: {accept: 'application/json'}},
+        },
+        expected: true,
+      },
+      {
+        data: {
+          method: 'GET',
+        },
+        expected: false,
+      },
+    ])('should have isNetworkDetailsSetup=$expected', ({data, expected}) => {
+      const startTimestamp = new Date();
+      const endTimestamp = new Date();
+      const replay = ReplayReader.factory({
+        attachments: [
+          TestStubs.Replay.SpanFrameEvent({
+            timestamp: startTimestamp,
+            data: {
+              payload: TestStubs.Replay.RequestFrame({
+                op: 'resource.fetch',
+                startTimestamp,
+                endTimestamp,
+                description: '/api/0/issues/',
+                data,
+              }),
+            },
+          }),
+        ],
+        errors: [],
+        replayRecord,
+      });
+
+      expect(replay?.isNetworkDetailsSetup()).toBe(expected);
+    });
+  });
+});

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

@@ -11,6 +11,7 @@ import {
   spansFactory,
 } from 'sentry/utils/replays/replayDataUtils';
 import splitAttachmentsByType from 'sentry/utils/replays/splitAttachmentsByType';
+import {EventType} from 'sentry/utils/replays/types';
 import type {
   MemorySpan,
   NetworkSpan,
@@ -161,7 +162,7 @@ export default class ReplayReader {
 
   sdkConfig = memoize(() => {
     const found = this.rrwebEvents.find(
-      event => event.type === 5 && event.data.tag === 'options'
+      event => event.type === EventType.Custom && event.data.tag === 'options'
     ) as undefined | RecordingOptions;
     return found?.data?.payload;
   });