Просмотр исходного кода

test(replay): Add unit tests for useReplayData hook (#45444)

Adds new fixtures, and use the to test `useReplayData()`
The new fixtures are directly inspired by the helpers inside of
https://github.com/getsentry/sentry/blob/master/src/sentry/replays/testutils.py#L200

The test/assertions inside of `useReplayData` are also streamlined. We
shouldn't assert that the resulting ReplayReader instance is correct,
it's more direct to assert that the arguments for that class are being
correctly fetched and combined. Testing that class directly happens
elsewhere.

https://github.com/getsentry/sentry/pull/45548 is a followup to remove
some less flexible test stubs, and let these new mocks get more use.

Fixes #38852
Ryan Albrecht 2 лет назад
Родитель
Сommit
21c4425a32

+ 16 - 0
fixtures/js-stubs/replayError.ts

@@ -0,0 +1,16 @@
+import type {ReplayError as TReplayError} from 'sentry/views/replays/types';
+
+export function ReplayError(
+  error: Partial<TReplayError> & Pick<TReplayError, 'id' | 'issue' | 'timestamp'>
+): TReplayError {
+  return {
+    'error.type': [] as string[],
+    'error.value': [] as string[],
+    id: error.id,
+    issue: error.issue,
+    'issue.id': 3740335939,
+    'project.name': 'javascript',
+    timestamp: error.id,
+    title: 'A Redirect with :orgId param on customer domain',
+  };
+}

+ 61 - 0
fixtures/js-stubs/replayRecord.ts

@@ -0,0 +1,61 @@
+import {duration} from 'moment';
+
+import type {ReplayRecord as TReplayRecord} from 'sentry/views/replays/types';
+
+export function ReplayRecord(replayRecord: Partial<TReplayRecord> = {}): TReplayRecord {
+  return {
+    activity: 0,
+    browser: {
+      name: 'Other',
+      version: '',
+    },
+    count_errors: 1,
+    count_segments: 14,
+    count_urls: 1,
+    device: {
+      name: '',
+      brand: '',
+      model_id: '',
+      family: 'Other',
+    },
+    dist: '',
+    duration: duration(84000),
+    environment: 'demo',
+    error_ids: ['5c83aaccfffb4a708ae893bad9be3a1c'],
+    finished_at: new Date('Sep 22, 2022 5:00:03 PM UTC'),
+    id: '761104e184c64d439ee1014b72b4d83b',
+    longest_transaction: 0,
+    os: {
+      name: 'Other',
+      version: '',
+    },
+    platform: 'javascript',
+    project_id: '6273278',
+    releases: ['1.0.0', '2.0.0'],
+    sdk: {
+      name: 'sentry.javascript.browser',
+      version: '7.1.1',
+    },
+    started_at: new Date('Sep 22, 2022 4:58:39 PM UTC'),
+    tags: {
+      'browser.name': ['Other'],
+      'device.family': ['Other'],
+      'os.name': ['Other'],
+      platform: ['javascript'],
+      releases: ['1.0.0', '2.0.0'],
+      'sdk.name': ['sentry.javascript.browser'],
+      'sdk.version': ['7.1.1'],
+      'user.ip': ['127.0.0.1'],
+    },
+    trace_ids: [],
+    urls: ['http://localhost:3000/'],
+    user: {
+      id: '',
+      username: '',
+      email: '',
+      ip: '127.0.0.1',
+      display_name: '127.0.0.1',
+    },
+    ...replayRecord,
+  };
+}

+ 185 - 0
fixtures/js-stubs/replaySegments.ts

@@ -0,0 +1,185 @@
+import {EventType} from '@sentry-internal/rrweb';
+import {serializedNodeWithId} from '@sentry-internal/rrweb-snapshot';
+
+type FullSnapshotEvent = {
+  data: {
+    initialOffset: {
+      left: number;
+      top: number;
+    };
+    node: serializedNodeWithId;
+  };
+  timestamp: number;
+  type: EventType.FullSnapshot;
+};
+
+type BaseReplayProps = {
+  timestamp: Date;
+};
+
+export function ReplaySegmentInit({
+  height = 600,
+  href = 'http://localhost/',
+  timestamp = new Date(),
+  width = 800,
+}: BaseReplayProps & {
+  height: number;
+  href: string;
+  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 ReplaySegmentFullsnapshot({
+  timestamp,
+  childNodes,
+}: BaseReplayProps & {childNodes: serializedNodeWithId[]}): [FullSnapshotEvent] {
+  return [
+    {
+      type: EventType.FullSnapshot,
+      timestamp: timestamp.getTime(),
+      data: {
+        initialOffset: {
+          top: 0,
+          left: 0,
+        },
+        node: {
+          type: 0, // NodeType.DocumentType
+          id: 0,
+          tagName: 'html',
+          attributes: {},
+          childNodes: [
+            ReplayRRWebNode({
+              tagName: 'body',
+              attributes: {
+                style:
+                  'margin:0; font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu;',
+              },
+              childNodes,
+            }),
+          ],
+        },
+      },
+    },
+  ];
+}
+
+export function ReplaySegmentConsole({timestamp = new Date()}: BaseReplayProps) {
+  return ReplaySegmentBreadcrumb({
+    timestamp,
+    payload: {
+      timestamp: timestamp.getTime() / 1000, // sentry data inside rrweb is in seconds
+      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.',
+    },
+  });
+}
+
+export function ReplaySegmentNavigation({
+  timestamp = new Date(),
+  hrefFrom = '/',
+  hrefTo = '/profile/',
+}: BaseReplayProps & {hrefFrom: string; hrefTo: string}) {
+  return ReplaySegmentBreadcrumb({
+    timestamp,
+    payload: {
+      type: 'default',
+      category: 'navigation',
+      data: {
+        from: hrefFrom,
+        to: hrefTo,
+      },
+    },
+  });
+}
+
+export function ReplaySegmentBreadcrumb({
+  timestamp = new Date(),
+  payload,
+}: BaseReplayProps & {payload: any}) {
+  return [
+    {
+      type: EventType.Custom,
+      timestamp: timestamp.getTime(), // rrweb timestamps are in ms
+      data: {
+        tag: 'breadcrumb',
+        payload,
+      },
+    },
+  ];
+}
+
+const nextRRWebId = (function () {
+  let __rrwebID = 0;
+  return () => ++__rrwebID;
+})();
+
+export function ReplayRRWebNode({
+  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: 2, // NodeType.Element
+      id,
+      tagName,
+      attributes: attributes ?? {},
+      childNodes: childNodes ?? [],
+    };
+  }
+  return {
+    type: 3, // NodeType.Text
+    id,
+    textContent: textContent ?? '',
+  };
+}
+
+export function ReplayRRWebDivHelloWorld() {
+  return ReplayRRWebNode({
+    tagName: 'div',
+    childNodes: [
+      ReplayRRWebNode({
+        tagName: 'h1',
+        attributes: {style: 'text-align: center;'},
+        childNodes: [
+          ReplayRRWebNode({
+            textContent: 'Hello World',
+          }),
+        ],
+      }),
+    ],
+  });
+}

+ 11 - 0
fixtures/js-stubs/types.tsx

@@ -1,3 +1,5 @@
+import type {ReplayRecord} from 'sentry/views/replays/types';
+
 type SimpleStub<T = any> = () => T;
 
 type OverridableStub<T = any> = (params?: Partial<T>) => T;
@@ -97,7 +99,16 @@ type TestStubFixtures = {
   PublishedApps: SimpleStub;
   PullRequest: OverridableStub;
   Release: (params?: any, healthParams?: any) => any;
+  ReplayError: OverridableStub;
+  ReplayRRWebDivHelloWorld: OverridableStub;
+  ReplayRRWebNode: OverridableStub;
   ReplayReaderParams: OverridableStub;
+  ReplayRecord: OverridableStub<ReplayRecord>;
+  ReplaySegmentBreadcrumb: OverridableStub;
+  ReplaySegmentConsole: OverridableStub;
+  ReplaySegmentFullsnapshot: OverridableStub;
+  ReplaySegmentInit: OverridableStub;
+  ReplaySegmentNavigation: OverridableStub;
   Repository: OverridableStub;
   RepositoryProjectPathConfig: OverridableStub;
   Search: OverridableStub;

+ 234 - 108
static/app/utils/replays/hooks/useReplayData.spec.tsx

@@ -1,168 +1,294 @@
+import {duration} from 'moment';
+
 import {initializeOrg} from 'sentry-test/initializeOrg';
 import {reactHooks} from 'sentry-test/reactTestingLibrary';
 
-import {BreadcrumbType} from 'sentry/types/breadcrumbs';
 import useReplayData from 'sentry/utils/replays/hooks/useReplayData';
-import {ReplayError} from 'sentry/views/replays/types';
+import ReplayReader from 'sentry/utils/replays/replayReader';
+import type {ReplayRecord} from 'sentry/views/replays/types';
 
 jest.useFakeTimers();
+jest.spyOn(ReplayReader, 'factory');
+
+const {organization, project} = initializeOrg();
+
+const MockedReplayReaderFactory = ReplayReader.factory as jest.MockedFunction<
+  typeof ReplayReader.factory
+>;
 
-const {organization} = initializeOrg();
-const replayReaderParams = TestStubs.ReplayReaderParams();
-const HYDRATED_REPLAY = replayReaderParams.replayRecord;
-const RAW_REPLAY = TestStubs.ReplayReaderParams({
-  replayRecord: {
+function getMockReplayRecord(replayRecord?: Partial<ReplayRecord>) {
+  const HYDRATED_REPLAY = TestStubs.ReplayRecord(replayRecord);
+  const RAW_REPLAY = {
+    ...HYDRATED_REPLAY,
     duration: HYDRATED_REPLAY.duration.asMilliseconds() / 1000,
     started_at: HYDRATED_REPLAY.started_at.toString(),
     finished_at: HYDRATED_REPLAY.finished_at.toString(),
-    tags: {},
-  },
-}).replayRecord;
-const MOCK_ATTACHMENTS = replayReaderParams.attachments;
-const MOCK_ERRORS: ReplayError[] = [
-  {
-    'error.type': [] as string[],
-    'error.value': [] as string[],
-    id: '1d50320db4a2423cb15e63b905ca69ea',
-    issue: 'JAVASCRIPT-123E',
-    'issue.id': 3740335939,
-    'project.name': 'javascript',
-    timestamp: '2023-01-01T10:23:16+00:00',
-    title: 'ARedirect with :orgId param on customer domain',
-  },
-];
-
-const ORG_SLUG = organization.slug;
-const PROJECT_SLUG = 'project-slug';
-const REPLAY_ID = RAW_REPLAY.id;
-
-const EXPECT_INIT_RRWEB_EVENT = expect.objectContaining({
-  type: 0,
-});
+  };
 
-const EXPECT_END_RRWEB_EVENT = expect.objectContaining({
-  type: 5, // EventType.Custom,
-  data: expect.objectContaining({
-    tag: 'replay-end',
-  }),
-});
-
-const EXPECT_REPLAY_INIT = expect.objectContaining({
-  type: BreadcrumbType.INIT,
-  data: expect.objectContaining({
-    action: 'replay-init',
-    label: 'Start recording',
-  }),
-});
-
-const EXPECT_ISSUE_CRUMB = expect.objectContaining({
-  category: 'issue',
-  description: 'Error',
-  data: expect.objectContaining({
-    groupShortId: 'JAVASCRIPT-123E',
-  }),
-});
+  return {
+    mockReplayResponse: RAW_REPLAY,
+    expectedReplay: HYDRATED_REPLAY,
+  };
+}
 
 describe('useReplayData', () => {
   beforeEach(() => {
     MockApiClient.clearMockResponses();
-    MockApiClient.asyncDelay;
   });
 
-  it('should fetch the data for a given project + replayId + org', async () => {
+  it('should hydrate the replayRecord', async () => {
+    const {mockReplayResponse, expectedReplay} = getMockReplayRecord({
+      count_errors: 0,
+      count_segments: 0,
+      error_ids: [],
+    });
+
+    MockApiClient.addMockResponse({
+      url: `/projects/${organization.slug}/${project.slug}/replays/${mockReplayResponse.id}/`,
+      body: {data: mockReplayResponse},
+    });
+
+    const {result, waitForNextUpdate} = reactHooks.renderHook(useReplayData, {
+      initialProps: {
+        replaySlug: `${project.slug}:${mockReplayResponse.id}`,
+        orgSlug: organization.slug,
+      },
+    });
+
+    await waitForNextUpdate();
+
+    expect(result.current).toEqual({
+      fetchError: undefined,
+      fetching: false,
+      onRetry: expect.any(Function),
+      replay: expect.any(ReplayReader),
+      replayRecord: expectedReplay,
+    });
+  });
+
+  it('should concat N segment responses and pass them into ReplayReader', async () => {
+    const startedAt = new Date('12:00:00 01-01-2023');
+    const finishedAt = new Date('12:00:10 01-01-2023');
+
+    const {mockReplayResponse, expectedReplay} = getMockReplayRecord({
+      started_at: startedAt,
+      finished_at: finishedAt,
+      duration: duration(10, 'seconds'),
+      count_errors: 0,
+      count_segments: 2,
+      error_ids: [],
+    });
+
+    const mockSegmentResponse1 = TestStubs.ReplaySegmentInit({timestamp: startedAt});
+    const mockSegmentResponse2 = [
+      ...TestStubs.ReplaySegmentConsole({timestamp: startedAt}),
+      ...TestStubs.ReplaySegmentNavigation({timestamp: startedAt}),
+    ];
+
+    MockApiClient.addMockResponse({
+      url: `/projects/${organization.slug}/${project.slug}/replays/${mockReplayResponse.id}/`,
+      body: {data: mockReplayResponse},
+    });
+    const mockedSegmentsCall1 = MockApiClient.addMockResponse({
+      url: `/projects/${organization.slug}/${project.slug}/replays/${mockReplayResponse.id}/recording-segments/`,
+      body: mockSegmentResponse1,
+      match: [(_url, options) => options.query?.cursor === '1:0:1'],
+    });
+    const mockedSegmentsCall2 = MockApiClient.addMockResponse({
+      url: `/projects/${organization.slug}/${project.slug}/replays/${mockReplayResponse.id}/recording-segments/`,
+      body: mockSegmentResponse2,
+      match: [(_url, options) => options.query?.cursor === '1:1:0'],
+    });
+
+    const {waitForNextUpdate} = reactHooks.renderHook(useReplayData, {
+      initialProps: {
+        replaySlug: `${project.slug}:${mockReplayResponse.id}`,
+        orgSlug: organization.slug,
+        segmentsPerPage: 1,
+      },
+    });
+
+    jest.runAllTimers();
+    await waitForNextUpdate();
+
+    expect(mockedSegmentsCall1).toHaveBeenCalledTimes(1);
+    expect(mockedSegmentsCall2).toHaveBeenCalledTimes(1);
+
+    expect(MockedReplayReaderFactory).toHaveBeenLastCalledWith({
+      attachments: [...mockSegmentResponse1, ...mockSegmentResponse2],
+      replayRecord: expectedReplay,
+      errors: [],
+    });
+  });
+
+  it('should concat N error responses and pass them through to Replay Reader', async () => {
+    const ERROR_IDS = [
+      '5c83aaccfffb4a708ae893bad9be3a1c',
+      '6d94aaccfffb4a708ae893bad9be3a1c',
+    ];
+    const startedAt = new Date('12:00:00 01-01-2023');
+    const finishedAt = new Date('12:00:10 01-01-2023');
+
+    const {mockReplayResponse, expectedReplay} = getMockReplayRecord({
+      started_at: startedAt,
+      finished_at: finishedAt,
+      duration: duration(10, 'seconds'),
+      count_errors: 2,
+      count_segments: 0,
+      error_ids: ERROR_IDS,
+    });
+
+    const mockErrorResponse1 = [
+      TestStubs.ReplayError({
+        id: ERROR_IDS[0],
+        issue: 'JAVASCRIPT-123E',
+        timestamp: startedAt,
+      }),
+    ];
+    const mockErrorResponse2 = [
+      TestStubs.ReplayError({
+        id: ERROR_IDS[1],
+        issue: 'JAVASCRIPT-789Z',
+        timestamp: startedAt,
+      }),
+    ];
+
+    MockApiClient.addMockResponse({
+      url: `/projects/${organization.slug}/${project.slug}/replays/${mockReplayResponse.id}/`,
+      body: {data: mockReplayResponse},
+    });
+    const mockedErrorsCall1 = MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/replays-events-meta/`,
+      body: {data: mockErrorResponse1},
+      match: [(_url, options) => options.query?.query === `id:[${ERROR_IDS[0]}]`],
+    });
+    const mockedErrorsCall2 = MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/replays-events-meta/`,
+      body: {data: mockErrorResponse2},
+      match: [(_url, options) => options.query?.query === `id:[${ERROR_IDS[1]}]`],
+    });
+
+    const {waitForNextUpdate} = reactHooks.renderHook(useReplayData, {
+      initialProps: {
+        replaySlug: `${project.slug}:${mockReplayResponse.id}`,
+        orgSlug: organization.slug,
+        errorsPerPage: 1,
+      },
+    });
+
+    jest.runAllTimers();
+    await waitForNextUpdate();
+
+    expect(mockedErrorsCall1).toHaveBeenCalledTimes(1);
+    expect(mockedErrorsCall2).toHaveBeenCalledTimes(1);
+
+    expect(MockedReplayReaderFactory).toHaveBeenLastCalledWith({
+      attachments: [],
+      replayRecord: expectedReplay,
+      errors: [...mockErrorResponse1, ...mockErrorResponse2],
+    });
+  });
+
+  it('should incrementally load attachments and errors', async () => {
+    const ERROR_ID = '5c83aaccfffb4a708ae893bad9be3a1c';
+    const startedAt = new Date('12:00:00 01-01-2023');
+    const finishedAt = new Date('12:00:10 01-01-2023');
+
+    const {mockReplayResponse, expectedReplay} = getMockReplayRecord({
+      started_at: startedAt,
+      finished_at: finishedAt,
+      duration: duration(10, 'seconds'),
+      count_errors: 1,
+      count_segments: 1,
+      error_ids: [ERROR_ID],
+    });
+    const mockSegmentResponse = TestStubs.ReplaySegmentInit({timestamp: startedAt});
+    const mockErrorResponse = [
+      TestStubs.ReplayError({
+        id: ERROR_ID,
+        issue: 'JAVASCRIPT-123E',
+        timestamp: startedAt,
+      }),
+    ];
+
     const mockedReplayCall = MockApiClient.addMockResponse({
       asyncDelay: 1,
-      url: `/projects/${ORG_SLUG}/${PROJECT_SLUG}/replays/${REPLAY_ID}/`,
-      body: {data: RAW_REPLAY},
+      url: `/projects/${organization.slug}/${project.slug}/replays/${mockReplayResponse.id}/`,
+      body: {data: mockReplayResponse},
     });
 
     const mockedSegmentsCall = MockApiClient.addMockResponse({
       asyncDelay: 100, // Simulate 100ms response time
-      url: `/projects/${ORG_SLUG}/${PROJECT_SLUG}/replays/${REPLAY_ID}/recording-segments/`,
-      body: MOCK_ATTACHMENTS,
+      url: `/projects/${organization.slug}/${project.slug}/replays/${mockReplayResponse.id}/recording-segments/`,
+      body: mockSegmentResponse,
     });
 
     const mockedEventsMetaCall = MockApiClient.addMockResponse({
       asyncDelay: 250, // Simulate 250ms response time
-      url: `/organizations/${ORG_SLUG}/replays-events-meta/`,
-      body: {data: MOCK_ERRORS},
+      url: `/organizations/${organization.slug}/replays-events-meta/`,
+      body: {data: mockErrorResponse},
     });
 
     const {result, waitForNextUpdate} = reactHooks.renderHook(useReplayData, {
       initialProps: {
-        replaySlug: `${PROJECT_SLUG}:${REPLAY_ID}`,
-        orgSlug: ORG_SLUG,
+        replaySlug: `${project.slug}:${mockReplayResponse.id}`,
+        orgSlug: organization.slug,
       },
     });
 
-    // Immediately we will see the replay call is made
-    expect(mockedReplayCall).toHaveBeenCalledTimes(1);
-    expect(mockedEventsMetaCall).not.toHaveBeenCalledTimes(1);
-    expect(mockedSegmentsCall).not.toHaveBeenCalledTimes(1);
-    expect(result.current).toEqual({
+    const expectedReplayData = {
       fetchError: undefined,
       fetching: true,
       onRetry: expect.any(Function),
       replay: null,
       replayRecord: undefined,
+    } as Record<string, unknown>;
+
+    // Immediately we will see the replay call is made
+    expect(mockedReplayCall).toHaveBeenCalledTimes(1);
+    expect(mockedEventsMetaCall).not.toHaveBeenCalledTimes(1);
+    expect(mockedSegmentsCall).not.toHaveBeenCalledTimes(1);
+    expect(MockedReplayReaderFactory).toHaveBeenLastCalledWith({
+      attachments: [],
+      replayRecord: undefined,
+      errors: [],
     });
+    expect(result.current).toEqual(expectedReplayData);
 
     jest.advanceTimersByTime(10);
     await waitForNextUpdate();
 
-    // Afterwards we see the attachments & errors requests are made, no data has arrived
+    // Afterwards we see the attachments & errors requests are made
     expect(mockedReplayCall).toHaveBeenCalledTimes(1);
     expect(mockedEventsMetaCall).toHaveBeenCalledTimes(1);
     expect(mockedSegmentsCall).toHaveBeenCalledTimes(1);
-    expect(result.current).toEqual({
-      fetchError: undefined,
-      fetching: true,
-      onRetry: expect.any(Function),
-      replay: expect.objectContaining({
-        replayRecord: HYDRATED_REPLAY,
-        rrwebEvents: [EXPECT_END_RRWEB_EVENT],
-        breadcrumbs: [EXPECT_REPLAY_INIT],
-        consoleCrumbs: [],
-        networkSpans: [],
-        memorySpans: [],
-      }),
-      replayRecord: HYDRATED_REPLAY,
+    expect(MockedReplayReaderFactory).toHaveBeenLastCalledWith({
+      attachments: [],
+      replayRecord: expectedReplay,
+      errors: [],
     });
+    expectedReplayData.replayRecord = expectedReplay;
+    expectedReplayData.replay = expect.any(ReplayReader);
+    expect(result.current).toEqual(expectedReplayData);
 
     jest.advanceTimersByTime(100);
     await waitForNextUpdate();
 
     // Next we see that some rrweb data has arrived
-    expect(result.current).toEqual(
-      expect.objectContaining({
-        fetching: true,
-        replay: expect.objectContaining({
-          rrwebEvents: expect.arrayContaining([
-            EXPECT_INIT_RRWEB_EVENT,
-            EXPECT_END_RRWEB_EVENT,
-          ]),
-          breadcrumbs: [EXPECT_REPLAY_INIT],
-          consoleCrumbs: [],
-        }),
-      })
-    );
+    expect(MockedReplayReaderFactory).toHaveBeenLastCalledWith({
+      attachments: mockSegmentResponse,
+      replayRecord: expectedReplay,
+      errors: [],
+    });
 
     jest.advanceTimersByTime(250);
     await waitForNextUpdate();
 
     // Finally we see fetching is complete, errors are here too
-    expect(result.current).toEqual(
-      expect.objectContaining({
-        fetching: false,
-        replay: expect.objectContaining({
-          rrwebEvents: expect.arrayContaining([
-            EXPECT_INIT_RRWEB_EVENT,
-            EXPECT_END_RRWEB_EVENT,
-          ]),
-          breadcrumbs: [EXPECT_REPLAY_INIT, EXPECT_ISSUE_CRUMB],
-          consoleCrumbs: [EXPECT_ISSUE_CRUMB],
-        }),
-      })
-    );
+    expect(MockedReplayReaderFactory).toHaveBeenLastCalledWith({
+      attachments: mockSegmentResponse,
+      replayRecord: expectedReplay,
+      errors: mockErrorResponse,
+    });
   });
 });

+ 27 - 18
static/app/utils/replays/hooks/useReplayData.tsx

@@ -8,8 +8,6 @@ import RequestError from 'sentry/utils/requestError/requestError';
 import useApi from 'sentry/utils/useApi';
 import type {ReplayError, ReplayRecord} from 'sentry/views/replays/types';
 
-const ERRORS_PER_PAGE = 50; // Need to make sure the url is not too large
-
 type State = {
   /**
    * If any request returned an error then nothing is being returned
@@ -35,6 +33,20 @@ type Options = {
    * The projectSlug and replayId concatenated together
    */
   replaySlug: string;
+
+  /**
+   * Default: 50
+   * You can override this for testing
+   *
+   * Be mindful that the list of error-ids will appear in the GET request url,
+   * so don't make the url string too large!
+   */
+  errorsPerPage?: number;
+  /**
+   * Default: 100
+   * You can override this for testing
+   */
+  segmentsPerPage?: number;
 };
 
 interface Result {
@@ -52,12 +64,6 @@ const INITIAL_STATE: State = Object.freeze({
   fetchingReplay: true,
 });
 
-function responseToAttachments(segment: unknown[]) {
-  // Each segment includes an array of attachments
-  // Therefore we flatten 1 levels deep
-  return segment.flat(1);
-}
-
 /**
  * A react hook to load core replay data over the network.
  *
@@ -82,7 +88,12 @@ function responseToAttachments(segment: unknown[]) {
  * @param {orgSlug, replaySlug} Where to find the root replay event
  * @returns An object representing a unified result of the network requests. Either a single `ReplayReader` data object or fetch errors.
  */
-function useReplayData({replaySlug, orgSlug}: Options): Result {
+function useReplayData({
+  replaySlug,
+  orgSlug,
+  errorsPerPage = 50,
+  segmentsPerPage = 100,
+}: Options): Result {
   const [projectSlug, replayId] = replaySlug.split(':');
 
   const api = useApi();
@@ -112,12 +123,10 @@ function useReplayData({replaySlug, orgSlug}: Options): Result {
       return;
     }
 
-    const perPage = 100;
-
-    const pages = Math.ceil(replayRecord.count_segments / 100);
+    const pages = Math.ceil(replayRecord.count_segments / segmentsPerPage);
     const cursors = new Array(pages)
       .fill(0)
-      .map((_, i) => `${perPage}:${i}:${i === 0 ? 1 : 0}`);
+      .map((_, i) => `${segmentsPerPage}:${i}:${i === 0 ? 1 : 0}`);
 
     await Promise.allSettled(
       cursors.map(cursor => {
@@ -126,19 +135,19 @@ function useReplayData({replaySlug, orgSlug}: Options): Result {
           {
             query: {
               download: true,
-              per_page: perPage,
+              per_page: segmentsPerPage,
               cursor,
             },
           }
         );
         promise.then(response => {
-          setAttachments(prev => (prev ?? []).concat(responseToAttachments(response)));
+          setAttachments(prev => (prev ?? []).concat(...response));
         });
         return promise;
       })
     );
     setState(prev => ({...prev, fetchingAttachments: false}));
-  }, [api, orgSlug, projectSlug, replayRecord]);
+  }, [segmentsPerPage, api, orgSlug, projectSlug, replayRecord]);
 
   const fetchErrors = useCallback(async () => {
     if (!replayRecord) {
@@ -157,7 +166,7 @@ function useReplayData({replaySlug, orgSlug}: Options): Result {
     const finishedAtClone = new Date(replayRecord.finished_at);
     finishedAtClone.setSeconds(finishedAtClone.getSeconds() + 1);
 
-    const chunks = chunk(replayRecord.error_ids, ERRORS_PER_PAGE);
+    const chunks = chunk(replayRecord.error_ids, errorsPerPage);
     await Promise.allSettled(
       chunks.map(errorIds => {
         const promise = api.requestPromise(
@@ -177,7 +186,7 @@ function useReplayData({replaySlug, orgSlug}: Options): Result {
       })
     );
     setState(prev => ({...prev, fetchingErrors: false}));
-  }, [api, orgSlug, replayRecord]);
+  }, [errorsPerPage, api, orgSlug, replayRecord]);
 
   const onError = useCallback(error => {
     Sentry.captureException(error);

+ 7 - 0
tests/js/sentry-test/loadFixtures.ts

@@ -106,6 +106,13 @@ const SPECIAL_MAPPING = {
   DiscoverSavedQuery: 'discover.js',
   VercelProvider: 'vercelIntegration.js',
   TagValues: 'tagvalues.js',
+  ReplayRRWebDivHelloWorld: 'replaySegments.ts',
+  ReplayRRWebNode: 'replaySegments.ts',
+  ReplaySegmentBreadcrumb: 'replaySegments.ts',
+  ReplaySegmentConsole: 'replaySegments.ts',
+  ReplaySegmentFullsnapshot: 'replaySegments.ts',
+  ReplaySegmentInit: 'replaySegments.ts',
+  ReplaySegmentNavigation: 'replaySegments.ts',
 };
 
 function tryRequire(dir: string, name: string): any {