useReplayData.spec.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  1. import type {ReactNode} from 'react';
  2. import {duration} from 'moment-timezone';
  3. import {
  4. ReplayConsoleEventFixture,
  5. ReplayNavigateEventFixture,
  6. } from 'sentry-fixture/replay/helpers';
  7. import {RRWebInitFrameEventsFixture} from 'sentry-fixture/replay/rrweb';
  8. import {ReplayErrorFixture} from 'sentry-fixture/replayError';
  9. import {ReplayRecordFixture} from 'sentry-fixture/replayRecord';
  10. import {initializeOrg} from 'sentry-test/initializeOrg';
  11. import {makeTestQueryClient} from 'sentry-test/queryClient';
  12. import {renderHook, waitFor} from 'sentry-test/reactTestingLibrary';
  13. import {DiscoverDatasets} from 'sentry/utils/discover/types';
  14. import {QueryClientProvider} from 'sentry/utils/queryClient';
  15. import useReplayData from 'sentry/utils/replays/hooks/useReplayData';
  16. import useProjects from 'sentry/utils/useProjects';
  17. import type {ReplayRecord} from 'sentry/views/replays/types';
  18. jest.mock('sentry/utils/useProjects');
  19. const {organization, project} = initializeOrg();
  20. jest.mocked(useProjects).mockReturnValue({
  21. fetching: false,
  22. projects: [project],
  23. fetchError: null,
  24. hasMore: false,
  25. initiallyLoaded: true,
  26. onSearch: () => Promise.resolve(),
  27. reloadProjects: jest.fn(),
  28. placeholders: [],
  29. });
  30. const mockInvalidateQueries = jest.fn();
  31. function wrapper({children}: {children?: ReactNode}) {
  32. const queryClient = makeTestQueryClient();
  33. queryClient.invalidateQueries = mockInvalidateQueries;
  34. return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
  35. }
  36. function getMockReplayRecord(replayRecord?: Partial<ReplayRecord>) {
  37. const HYDRATED_REPLAY = ReplayRecordFixture({
  38. ...replayRecord,
  39. project_id: project.id,
  40. });
  41. const RAW_REPLAY = {
  42. ...HYDRATED_REPLAY,
  43. duration: HYDRATED_REPLAY.duration.asSeconds(),
  44. started_at: HYDRATED_REPLAY.started_at.toString(),
  45. finished_at: HYDRATED_REPLAY.finished_at.toString(),
  46. };
  47. return {
  48. mockReplayResponse: RAW_REPLAY,
  49. expectedReplay: HYDRATED_REPLAY,
  50. };
  51. }
  52. describe('useReplayData', () => {
  53. beforeEach(() => {
  54. MockApiClient.clearMockResponses();
  55. mockInvalidateQueries.mockClear();
  56. });
  57. it('should hydrate the replayRecord', async () => {
  58. const {mockReplayResponse, expectedReplay} = getMockReplayRecord({
  59. count_errors: 0,
  60. count_segments: 0,
  61. error_ids: [],
  62. });
  63. MockApiClient.addMockResponse({
  64. url: `/organizations/${organization.slug}/replays/${mockReplayResponse.id}/`,
  65. body: {data: mockReplayResponse},
  66. });
  67. MockApiClient.addMockResponse({
  68. url: `/organizations/${organization.slug}/replays-events-meta/`,
  69. body: {
  70. data: [],
  71. },
  72. headers: {
  73. Link: [
  74. '<http://localhost/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:1:0"',
  75. '<http://localhost/?cursor=0:2:0>; rel="next"; results="false"; cursor="0:1:0"',
  76. ].join(','),
  77. },
  78. });
  79. MockApiClient.addMockResponse({
  80. url: `/organizations/${organization.slug}/replays/${mockReplayResponse.id}/`,
  81. body: {data: mockReplayResponse},
  82. });
  83. const {result} = renderHook(useReplayData, {
  84. wrapper,
  85. initialProps: {
  86. replayId: mockReplayResponse.id,
  87. orgSlug: organization.slug,
  88. },
  89. });
  90. await waitFor(() =>
  91. expect(result.current).toEqual({
  92. attachments: expect.any(Array),
  93. errors: expect.any(Array),
  94. fetchError: undefined,
  95. fetching: false,
  96. onRetry: expect.any(Function),
  97. projectSlug: project.slug,
  98. replayRecord: expectedReplay,
  99. })
  100. );
  101. });
  102. it('should concat N segment responses and pass them into ReplayReader', async () => {
  103. const startedAt = new Date('12:00:00 01-01-2023');
  104. const finishedAt = new Date('12:00:10 01-01-2023');
  105. const {mockReplayResponse, expectedReplay} = getMockReplayRecord({
  106. started_at: startedAt,
  107. finished_at: finishedAt,
  108. duration: duration(10, 'seconds'),
  109. count_errors: 0,
  110. count_segments: 2,
  111. error_ids: [],
  112. });
  113. MockApiClient.addMockResponse({
  114. url: `/organizations/${organization.slug}/replays-events-meta/`,
  115. body: {
  116. data: [],
  117. },
  118. headers: {
  119. Link: [
  120. '<http://localhost/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:1:0"',
  121. '<http://localhost/?cursor=0:2:0>; rel="next"; results="false"; cursor="0:1:0"',
  122. ].join(','),
  123. },
  124. });
  125. const mockSegmentResponse1 = RRWebInitFrameEventsFixture({
  126. timestamp: startedAt,
  127. });
  128. const mockSegmentResponse2 = [
  129. ReplayConsoleEventFixture({timestamp: startedAt}),
  130. ReplayNavigateEventFixture({
  131. startTimestamp: startedAt,
  132. endTimestamp: finishedAt,
  133. }),
  134. ];
  135. MockApiClient.addMockResponse({
  136. url: `/organizations/${organization.slug}/replays/${mockReplayResponse.id}/`,
  137. body: {data: mockReplayResponse},
  138. });
  139. const mockedSegmentsCall1 = MockApiClient.addMockResponse({
  140. url: `/projects/${organization.slug}/${project.slug}/replays/${mockReplayResponse.id}/recording-segments/`,
  141. body: mockSegmentResponse1,
  142. match: [(_url, options) => options.query?.cursor === '0:0:0'],
  143. });
  144. const mockedSegmentsCall2 = MockApiClient.addMockResponse({
  145. url: `/projects/${organization.slug}/${project.slug}/replays/${mockReplayResponse.id}/recording-segments/`,
  146. body: mockSegmentResponse2,
  147. match: [(_url, options) => options.query?.cursor === '0:1:0'],
  148. });
  149. const {result} = renderHook(useReplayData, {
  150. wrapper,
  151. initialProps: {
  152. replayId: mockReplayResponse.id,
  153. orgSlug: organization.slug,
  154. segmentsPerPage: 1,
  155. },
  156. });
  157. await waitFor(() => expect(mockedSegmentsCall1).toHaveBeenCalledTimes(1));
  158. expect(mockedSegmentsCall2).toHaveBeenCalledTimes(1);
  159. expect(result.current).toStrictEqual(
  160. expect.objectContaining({
  161. attachments: [...mockSegmentResponse1, ...mockSegmentResponse2],
  162. errors: [],
  163. replayRecord: expectedReplay,
  164. })
  165. );
  166. });
  167. it('should always fetch DISCOVER & ISSUE_PLATFORM errors', async () => {
  168. const startedAt = new Date('12:00:00 01-01-2023');
  169. const finishedAt = new Date('12:00:10 01-01-2023');
  170. const {mockReplayResponse, expectedReplay} = getMockReplayRecord({
  171. started_at: startedAt,
  172. finished_at: finishedAt,
  173. duration: duration(10, 'seconds'),
  174. count_errors: 0,
  175. count_segments: 0,
  176. error_ids: [],
  177. });
  178. MockApiClient.addMockResponse({
  179. url: `/organizations/${organization.slug}/replays/${mockReplayResponse.id}/`,
  180. body: {data: mockReplayResponse},
  181. });
  182. const mockedErrorEventsMetaCall = MockApiClient.addMockResponse({
  183. url: `/organizations/${organization.slug}/replays-events-meta/`,
  184. body: {},
  185. headers: {
  186. Link: [
  187. '<http://localhost/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1"',
  188. '<http://localhost/?cursor=0:2:0>; rel="next"; results="false"; cursor="0:1:0"',
  189. ].join(','),
  190. },
  191. match: [
  192. (_url, options) => options.query?.dataset === DiscoverDatasets.DISCOVER,
  193. (_url, options) => options.query?.query === `replayId:[${mockReplayResponse.id}]`,
  194. (_url, options) => options.query?.cursor === '0:0:0',
  195. ],
  196. });
  197. const mockedIssuePlatformEventsMetaCall = MockApiClient.addMockResponse({
  198. url: `/organizations/${organization.slug}/replays-events-meta/`,
  199. body: {},
  200. headers: {
  201. Link: [
  202. '<http://localhost/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1"',
  203. '<http://localhost/?cursor=0:2:0>; rel="next"; results="false"; cursor="0:1:0"',
  204. ].join(','),
  205. },
  206. match: [
  207. (_url, options) => options.query?.dataset === DiscoverDatasets.ISSUE_PLATFORM,
  208. (_url, options) => options.query?.query === `replayId:[${mockReplayResponse.id}]`,
  209. (_url, options) => options.query?.cursor === '0:0:0',
  210. ],
  211. });
  212. const {result} = renderHook(useReplayData, {
  213. wrapper,
  214. initialProps: {
  215. replayId: mockReplayResponse.id,
  216. orgSlug: organization.slug,
  217. errorsPerPage: 1,
  218. },
  219. });
  220. await waitFor(() => expect(mockedErrorEventsMetaCall).toHaveBeenCalledTimes(1));
  221. expect(mockedIssuePlatformEventsMetaCall).toHaveBeenCalledTimes(1);
  222. expect(result.current).toStrictEqual(
  223. expect.objectContaining({
  224. attachments: [],
  225. errors: [],
  226. replayRecord: expectedReplay,
  227. })
  228. );
  229. });
  230. it('should concat N error responses and pass them through to Replay Reader', async () => {
  231. const ERROR_IDS = [
  232. '5c83aaccfffb4a708ae893bad9be3a1c',
  233. '6d94aaccfffb4a708ae893bad9be3a1c',
  234. ];
  235. const startedAt = new Date('12:00:00 01-01-2023');
  236. const finishedAt = new Date('12:00:10 01-01-2023');
  237. const {mockReplayResponse, expectedReplay} = getMockReplayRecord({
  238. started_at: startedAt,
  239. finished_at: finishedAt,
  240. duration: duration(10, 'seconds'),
  241. count_errors: ERROR_IDS.length,
  242. count_segments: 0,
  243. error_ids: ERROR_IDS,
  244. });
  245. const mockErrorResponse1 = [
  246. ReplayErrorFixture({
  247. id: ERROR_IDS[0],
  248. issue: 'JAVASCRIPT-123E',
  249. timestamp: startedAt.toISOString(),
  250. }),
  251. ];
  252. const mockErrorResponse2 = [
  253. ReplayErrorFixture({
  254. id: ERROR_IDS[1],
  255. issue: 'JAVASCRIPT-789Z',
  256. timestamp: startedAt.toISOString(),
  257. }),
  258. ];
  259. const mockErrorResponse3 = [
  260. ReplayErrorFixture({
  261. id: ERROR_IDS[0],
  262. issue: 'JAVASCRIPT-123E',
  263. timestamp: startedAt.toISOString(),
  264. }),
  265. ];
  266. const mockErrorResponse4 = [
  267. ReplayErrorFixture({
  268. id: ERROR_IDS[1],
  269. issue: 'JAVASCRIPT-789Z',
  270. timestamp: startedAt.toISOString(),
  271. }),
  272. ];
  273. MockApiClient.addMockResponse({
  274. url: `/organizations/${organization.slug}/replays/${mockReplayResponse.id}/`,
  275. body: {data: mockReplayResponse},
  276. });
  277. const mockedErrorEventsMetaCall1 = MockApiClient.addMockResponse({
  278. url: `/organizations/${organization.slug}/replays-events-meta/`,
  279. body: {data: mockErrorResponse1},
  280. headers: {
  281. Link: [
  282. '<http://localhost/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1"',
  283. '<http://localhost/?cursor=0:2:0>; rel="next"; results="true"; cursor="0:1:0"',
  284. ].join(','),
  285. },
  286. match: [
  287. (_url, options) => options.query?.dataset === DiscoverDatasets.DISCOVER,
  288. (_url, options) => options.query?.query === `replayId:[${mockReplayResponse.id}]`,
  289. (_url, options) => options.query?.cursor === '0:0:0',
  290. ],
  291. });
  292. const mockedErrorEventsMetaCall2 = MockApiClient.addMockResponse({
  293. url: `/organizations/${organization.slug}/replays-events-meta/`,
  294. body: {data: mockErrorResponse2},
  295. headers: {
  296. Link: [
  297. '<http://localhost/?cursor=0:0:1>; rel="previous"; results="true"; cursor="0:1:0"',
  298. '<http://localhost/?cursor=0:2:0>; rel="next"; results="false"; cursor="0:2:0"',
  299. ].join(','),
  300. },
  301. match: [
  302. (_url, options) => options.query?.dataset === DiscoverDatasets.DISCOVER,
  303. (_url, options) => options.query?.query === `replayId:[${mockReplayResponse.id}]`,
  304. (_url, options) => options.query?.cursor === '0:1:0',
  305. ],
  306. });
  307. const mockedIssuePlatformEventsMetaCall1 = MockApiClient.addMockResponse({
  308. url: `/organizations/${organization.slug}/replays-events-meta/`,
  309. body: {data: mockErrorResponse3},
  310. headers: {
  311. Link: [
  312. '<http://localhost/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1"',
  313. '<http://localhost/?cursor=0:2:0>; rel="next"; results="true"; cursor="0:1:0"',
  314. ].join(','),
  315. },
  316. match: [
  317. (_url, options) => options.query?.dataset === DiscoverDatasets.ISSUE_PLATFORM,
  318. (_url, options) => options.query?.query === `replayId:[${mockReplayResponse.id}]`,
  319. (_url, options) => options.query?.cursor === '0:0:0',
  320. ],
  321. });
  322. const mockedIssuePlatformEventsMetaCall2 = MockApiClient.addMockResponse({
  323. url: `/organizations/${organization.slug}/replays-events-meta/`,
  324. body: {data: mockErrorResponse4},
  325. headers: {
  326. Link: [
  327. '<http://localhost/?cursor=0:0:1>; rel="previous"; results="true"; cursor="0:1:0"',
  328. '<http://localhost/?cursor=0:2:0>; rel="next"; results="false"; cursor="0:2:0"',
  329. ].join(','),
  330. },
  331. match: [
  332. (_url, options) => options.query?.dataset === DiscoverDatasets.ISSUE_PLATFORM,
  333. (_url, options) => options.query?.query === `replayId:[${mockReplayResponse.id}]`,
  334. (_url, options) => options.query?.cursor === '0:1:0',
  335. ],
  336. });
  337. const {result} = renderHook(useReplayData, {
  338. wrapper,
  339. initialProps: {
  340. replayId: mockReplayResponse.id,
  341. orgSlug: organization.slug,
  342. errorsPerPage: 1,
  343. },
  344. });
  345. await waitFor(() => expect(mockedErrorEventsMetaCall1).toHaveBeenCalledTimes(1));
  346. expect(mockedErrorEventsMetaCall2).toHaveBeenCalledTimes(1);
  347. expect(mockedIssuePlatformEventsMetaCall1).toHaveBeenCalledTimes(1);
  348. expect(mockedIssuePlatformEventsMetaCall2).toHaveBeenCalledTimes(1);
  349. expect(result.current).toStrictEqual(
  350. expect.objectContaining({
  351. attachments: [],
  352. errors: [
  353. ...mockErrorResponse1,
  354. ...mockErrorResponse2,
  355. ...mockErrorResponse3,
  356. ...mockErrorResponse4,
  357. ],
  358. replayRecord: expectedReplay,
  359. })
  360. );
  361. });
  362. it('should incrementally load attachments and errors', async () => {
  363. const ERROR_IDS = ['5c83aaccfffb4a708ae893bad9be3a1c'];
  364. const startedAt = new Date('12:00:00 01-01-2023');
  365. const finishedAt = new Date('12:00:10 01-01-2023');
  366. const {mockReplayResponse, expectedReplay} = getMockReplayRecord({
  367. started_at: startedAt,
  368. finished_at: finishedAt,
  369. duration: duration(10, 'seconds'),
  370. count_errors: ERROR_IDS.length,
  371. count_segments: 1,
  372. error_ids: ERROR_IDS,
  373. });
  374. const mockSegmentResponse = RRWebInitFrameEventsFixture({
  375. timestamp: startedAt,
  376. });
  377. const mockErrorResponse = [
  378. ReplayErrorFixture({
  379. id: ERROR_IDS[0],
  380. issue: 'JAVASCRIPT-123E',
  381. timestamp: startedAt.toISOString(),
  382. }),
  383. ];
  384. const mockedReplayCall = MockApiClient.addMockResponse({
  385. asyncDelay: 1,
  386. url: `/organizations/${organization.slug}/replays/${mockReplayResponse.id}/`,
  387. body: {data: mockReplayResponse},
  388. });
  389. const mockedSegmentsCall = MockApiClient.addMockResponse({
  390. asyncDelay: 100, // Simulate 100ms response time
  391. url: `/projects/${organization.slug}/${project.slug}/replays/${mockReplayResponse.id}/recording-segments/`,
  392. body: mockSegmentResponse,
  393. });
  394. const mockedErrorEventsMetaCall = MockApiClient.addMockResponse({
  395. asyncDelay: 250, // Simulate 250ms response time
  396. url: `/organizations/${organization.slug}/replays-events-meta/`,
  397. match: [MockApiClient.matchQuery({dataset: DiscoverDatasets.DISCOVER})],
  398. body: {data: mockErrorResponse},
  399. });
  400. const mockedIssuePlatformEventsMetaCall = MockApiClient.addMockResponse({
  401. asyncDelay: 250, // Simulate 250ms response time
  402. url: `/organizations/${organization.slug}/replays-events-meta/`,
  403. match: [MockApiClient.matchQuery({dataset: DiscoverDatasets.ISSUE_PLATFORM})],
  404. body: {data: mockErrorResponse},
  405. });
  406. const {result} = renderHook(useReplayData, {
  407. wrapper,
  408. initialProps: {
  409. replayId: mockReplayResponse.id,
  410. orgSlug: organization.slug,
  411. },
  412. });
  413. const expectedReplayData = {
  414. attachments: [],
  415. errors: [],
  416. fetchError: undefined,
  417. fetching: true,
  418. onRetry: expect.any(Function),
  419. projectSlug: null,
  420. replayRecord: undefined,
  421. } as Record<string, unknown>;
  422. // Immediately we will see the replay call is made
  423. expect(mockedReplayCall).toHaveBeenCalledTimes(1);
  424. expect(mockedErrorEventsMetaCall).not.toHaveBeenCalled();
  425. expect(mockedIssuePlatformEventsMetaCall).not.toHaveBeenCalled();
  426. expect(mockedSegmentsCall).not.toHaveBeenCalled();
  427. expect(result.current).toEqual(expectedReplayData);
  428. // Afterwards we see the attachments & errors requests are made
  429. await waitFor(() => expect(mockedReplayCall).toHaveBeenCalledTimes(1));
  430. await waitFor(() => expect(mockedErrorEventsMetaCall).toHaveBeenCalledTimes(1));
  431. await waitFor(() =>
  432. expect(mockedIssuePlatformEventsMetaCall).toHaveBeenCalledTimes(1)
  433. );
  434. expect(mockedSegmentsCall).toHaveBeenCalledTimes(1);
  435. expect(result.current).toStrictEqual(
  436. expect.objectContaining({
  437. attachments: [],
  438. errors: [],
  439. projectSlug: project.slug,
  440. replayRecord: expectedReplay,
  441. })
  442. );
  443. // Next we see that some rrweb data has arrived
  444. await waitFor(() =>
  445. expect(result.current).toStrictEqual(
  446. expect.objectContaining({
  447. attachments: mockSegmentResponse,
  448. errors: [],
  449. replayRecord: expectedReplay,
  450. })
  451. )
  452. );
  453. // Finally we see fetching is complete, errors are here too
  454. await waitFor(() =>
  455. expect(result.current).toStrictEqual(
  456. expect.objectContaining({
  457. attachments: mockSegmentResponse,
  458. // mockErrorResponse is the same between both responses
  459. errors: [...mockErrorResponse, ...mockErrorResponse],
  460. replayRecord: expectedReplay,
  461. })
  462. )
  463. );
  464. });
  465. it("should invalidate queries when result's 'onRetry' function is called", async () => {
  466. const {mockReplayResponse} = getMockReplayRecord({
  467. count_errors: 0,
  468. count_segments: 0,
  469. error_ids: [],
  470. });
  471. const replayId = mockReplayResponse.id;
  472. MockApiClient.addMockResponse({
  473. url: `/organizations/${organization.slug}/replays/${replayId}/`,
  474. body: {data: mockReplayResponse},
  475. });
  476. MockApiClient.addMockResponse({
  477. url: `/organizations/${organization.slug}/replays-events-meta/`,
  478. body: {
  479. data: [],
  480. },
  481. headers: {
  482. Link: [
  483. '<http://localhost/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:1:0"',
  484. '<http://localhost/?cursor=0:2:0>; rel="next"; results="false"; cursor="0:1:0"',
  485. ].join(','),
  486. },
  487. });
  488. const {result} = renderHook(useReplayData, {
  489. wrapper,
  490. initialProps: {
  491. replayId,
  492. orgSlug: organization.slug,
  493. },
  494. });
  495. // We need this 'await waitFor()' for the following assertions to pass:
  496. await waitFor(() => {
  497. expect(result.current).toBeTruthy();
  498. });
  499. result.current.onRetry();
  500. expect(mockInvalidateQueries).toHaveBeenCalledWith({
  501. queryKey: [`/organizations/${organization.slug}/replays/${replayId}/`],
  502. });
  503. expect(mockInvalidateQueries).toHaveBeenCalledWith({
  504. queryKey: [
  505. `/projects/${organization.slug}/${project.slug}/replays/${replayId}/recording-segments/`,
  506. ],
  507. });
  508. expect(mockInvalidateQueries).toHaveBeenCalledWith({
  509. queryKey: [`/organizations/${organization.slug}/replays-events-meta/`],
  510. });
  511. });
  512. });