groupReplays.spec.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  1. import {duration} from 'moment-timezone';
  2. import {GroupFixture} from 'sentry-fixture/group';
  3. import {ProjectFixture} from 'sentry-fixture/project';
  4. import {RRWebInitFrameEventsFixture} from 'sentry-fixture/replay/rrweb';
  5. import {ReplayListFixture} from 'sentry-fixture/replayList';
  6. import {ReplayRecordFixture} from 'sentry-fixture/replayRecord';
  7. import {initializeOrg} from 'sentry-test/initializeOrg';
  8. import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
  9. import {resetMockDate, setMockDate} from 'sentry-test/utils';
  10. import ProjectsStore from 'sentry/stores/projectsStore';
  11. import useLoadReplayReader from 'sentry/utils/replays/hooks/useLoadReplayReader';
  12. import ReplayReader from 'sentry/utils/replays/replayReader';
  13. import GroupReplays from 'sentry/views/issueDetails/groupReplays';
  14. const mockReplayCountUrl = '/organizations/org-slug/replay-count/';
  15. const mockReplayUrl = '/organizations/org-slug/replays/';
  16. const REPLAY_ID_1 = '346789a703f6454384f1de473b8b9fcc';
  17. const REPLAY_ID_2 = 'b05dae9b6be54d21a4d5ad9f8f02b780';
  18. jest.mock('sentry/utils/replays/hooks/useLoadReplayReader');
  19. const mockUseLoadReplayReader = jest.mocked(useLoadReplayReader);
  20. const mockEventTimestamp = new Date('2022-09-22T16:59:41Z');
  21. const mockEventTimestampMs = mockEventTimestamp.getTime();
  22. // Get replay data with the mocked replay reader params
  23. const mockReplay = ReplayReader.factory({
  24. replayRecord: ReplayRecordFixture({
  25. id: REPLAY_ID_1,
  26. browser: {
  27. name: 'Chrome',
  28. version: '110.0.0',
  29. },
  30. started_at: new Date('Sep 22, 2022 4:58:39 PM UTC'),
  31. finished_at: new Date(mockEventTimestampMs + 5_000),
  32. duration: duration(10, 'seconds'),
  33. }),
  34. errors: [],
  35. fetching: false,
  36. attachments: RRWebInitFrameEventsFixture({
  37. timestamp: new Date('Sep 22, 2022 4:58:39 PM UTC'),
  38. }),
  39. clipWindow: {
  40. startTimestampMs: mockEventTimestampMs - 5_000,
  41. endTimestampMs: mockEventTimestampMs + 5_000,
  42. },
  43. });
  44. mockUseLoadReplayReader.mockImplementation(() => {
  45. return {
  46. attachments: [],
  47. errors: [],
  48. fetchError: undefined,
  49. fetching: false,
  50. onRetry: jest.fn(),
  51. projectSlug: ProjectFixture().slug,
  52. replay: mockReplay,
  53. replayId: REPLAY_ID_1,
  54. replayRecord: ReplayRecordFixture({id: REPLAY_ID_1}),
  55. };
  56. });
  57. type InitializeOrgProps = {
  58. organizationProps?: {
  59. features?: string[];
  60. };
  61. };
  62. describe('GroupReplays', () => {
  63. const mockGroup = GroupFixture();
  64. function init({
  65. organizationProps = {features: ['session-replay']},
  66. }: InitializeOrgProps) {
  67. const mockProject = ProjectFixture();
  68. const {router, projects, organization} = initializeOrg({
  69. organization: {
  70. ...organizationProps,
  71. },
  72. projects: [mockProject],
  73. router: {
  74. routes: [
  75. {path: '/'},
  76. {path: '/organizations/:orgId/issues/:groupId/'},
  77. {path: 'replays/'},
  78. ],
  79. location: {
  80. pathname: '/organizations/org-slug/replays/',
  81. query: {},
  82. },
  83. params: {
  84. orgId: 'org-slug',
  85. groupId: mockGroup.id,
  86. },
  87. },
  88. });
  89. ProjectsStore.init();
  90. ProjectsStore.loadInitialData(projects);
  91. return {router, organization};
  92. }
  93. beforeEach(() => {
  94. MockApiClient.addMockResponse({
  95. url: `/organizations/org-slug/issues/${mockGroup.id}/`,
  96. body: mockGroup,
  97. });
  98. });
  99. afterEach(() => {
  100. resetMockDate();
  101. jest.clearAllMocks();
  102. MockApiClient.clearMockResponses();
  103. });
  104. describe('Replay Feature Disabled', () => {
  105. it("should show a message when the organization doesn't have access to the replay feature", () => {
  106. const {router, organization} = init({organizationProps: {features: []}});
  107. render(<GroupReplays />, {
  108. router,
  109. organization,
  110. });
  111. expect(
  112. screen.getByText("You don't have access to this feature")
  113. ).toBeInTheDocument();
  114. });
  115. });
  116. describe('Replay Feature Enabled', () => {
  117. it('should query the replay-count endpoint with the fetched replayIds', async () => {
  118. const {router, organization} = init({});
  119. const mockReplayCountApi = MockApiClient.addMockResponse({
  120. url: mockReplayCountUrl,
  121. body: {
  122. [mockGroup.id]: [REPLAY_ID_1, REPLAY_ID_2],
  123. },
  124. });
  125. const mockReplayApi = MockApiClient.addMockResponse({
  126. url: mockReplayUrl,
  127. body: {
  128. data: [],
  129. },
  130. });
  131. render(<GroupReplays />, {
  132. router,
  133. organization,
  134. });
  135. await waitFor(() => {
  136. expect(mockReplayCountApi).toHaveBeenCalledWith(
  137. mockReplayCountUrl,
  138. expect.objectContaining({
  139. query: {
  140. returnIds: true,
  141. data_source: 'discover',
  142. query: `issue.id:[${mockGroup.id}]`,
  143. statsPeriod: '90d',
  144. project: -1,
  145. },
  146. })
  147. );
  148. });
  149. // Expect api path to have the correct query params
  150. expect(mockReplayApi).toHaveBeenCalledWith(
  151. mockReplayUrl,
  152. expect.objectContaining({
  153. query: expect.objectContaining({
  154. environment: [],
  155. field: [
  156. 'activity',
  157. 'browser',
  158. 'count_dead_clicks',
  159. 'count_errors',
  160. 'count_rage_clicks',
  161. 'duration',
  162. 'finished_at',
  163. 'has_viewed',
  164. 'id',
  165. 'is_archived',
  166. 'os',
  167. 'project_id',
  168. 'started_at',
  169. 'user',
  170. ],
  171. per_page: 50,
  172. project: -1,
  173. queryReferrer: 'issueReplays',
  174. query: `id:[${REPLAY_ID_1},${REPLAY_ID_2}]`,
  175. sort: '-started_at',
  176. statsPeriod: '90d',
  177. }),
  178. })
  179. );
  180. });
  181. it('should show empty message when no replays are found', async () => {
  182. const {router, organization} = init({});
  183. const mockReplayCountApi = MockApiClient.addMockResponse({
  184. url: mockReplayCountUrl,
  185. body: {
  186. [mockGroup.id]: [REPLAY_ID_1, REPLAY_ID_2],
  187. },
  188. });
  189. const mockReplayApi = MockApiClient.addMockResponse({
  190. url: mockReplayUrl,
  191. body: {
  192. data: [],
  193. },
  194. });
  195. render(<GroupReplays />, {
  196. router,
  197. organization,
  198. });
  199. expect(
  200. await screen.findByText('There are no items to display')
  201. ).toBeInTheDocument();
  202. expect(mockReplayCountApi).toHaveBeenCalled();
  203. expect(mockReplayApi).toHaveBeenCalledTimes(1);
  204. });
  205. it('should display error message when api call fails', async () => {
  206. const {router, organization} = init({});
  207. const mockReplayCountApi = MockApiClient.addMockResponse({
  208. url: mockReplayCountUrl,
  209. body: {
  210. [mockGroup.id]: [REPLAY_ID_1, REPLAY_ID_2],
  211. },
  212. });
  213. const mockReplayApi = MockApiClient.addMockResponse({
  214. url: mockReplayUrl,
  215. statusCode: 500,
  216. body: {
  217. detail: 'Invalid number: asdf. Expected number.',
  218. },
  219. });
  220. render(<GroupReplays />, {
  221. router,
  222. organization,
  223. });
  224. expect(
  225. await screen.findByText(
  226. 'Sorry, the list of replays could not be loaded. Invalid number: asdf. Expected number.'
  227. )
  228. ).toBeInTheDocument();
  229. await waitFor(() => {
  230. expect(mockReplayCountApi).toHaveBeenCalled();
  231. });
  232. expect(mockReplayApi).toHaveBeenCalledTimes(1);
  233. });
  234. it('should display default error message when api call fails without a body', async () => {
  235. const {router, organization} = init({});
  236. const mockReplayCountApi = MockApiClient.addMockResponse({
  237. url: mockReplayCountUrl,
  238. body: {
  239. [mockGroup.id]: [REPLAY_ID_1, REPLAY_ID_2],
  240. },
  241. });
  242. const mockReplayApi = MockApiClient.addMockResponse({
  243. url: mockReplayUrl,
  244. statusCode: 500,
  245. body: {},
  246. });
  247. render(<GroupReplays />, {
  248. router,
  249. organization,
  250. });
  251. expect(
  252. await screen.findByText(
  253. 'Sorry, the list of replays could not be loaded. This could be due to invalid search parameters or an internal systems error.'
  254. )
  255. ).toBeInTheDocument();
  256. await waitFor(() => {
  257. expect(mockReplayCountApi).toHaveBeenCalled();
  258. });
  259. expect(mockReplayApi).toHaveBeenCalledTimes(1);
  260. });
  261. it('should show loading indicator when loading replays', async () => {
  262. const {router, organization} = init({});
  263. const mockReplayCountApi = MockApiClient.addMockResponse({
  264. url: mockReplayCountUrl,
  265. body: {
  266. [mockGroup.id]: [REPLAY_ID_1, REPLAY_ID_2],
  267. },
  268. });
  269. const mockReplayApi = MockApiClient.addMockResponse({
  270. url: mockReplayUrl,
  271. statusCode: 200,
  272. body: {
  273. data: [],
  274. },
  275. });
  276. render(<GroupReplays />, {
  277. router,
  278. organization,
  279. });
  280. expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
  281. await waitFor(() => {
  282. expect(mockReplayCountApi).toHaveBeenCalled();
  283. });
  284. expect(mockReplayApi).toHaveBeenCalledTimes(1);
  285. });
  286. it('should show a list of replays and have the correct values', async () => {
  287. const {router, organization} = init({});
  288. const mockReplayCountApi = MockApiClient.addMockResponse({
  289. url: mockReplayCountUrl,
  290. body: {
  291. [mockGroup.id]: [REPLAY_ID_1, REPLAY_ID_2],
  292. },
  293. });
  294. const mockReplayApi = MockApiClient.addMockResponse({
  295. url: mockReplayUrl,
  296. statusCode: 200,
  297. body: {
  298. data: [
  299. {
  300. ...ReplayListFixture()[0],
  301. count_errors: 1,
  302. duration: 52346,
  303. finished_at: new Date('2022-09-15T06:54:00+00:00'),
  304. id: REPLAY_ID_1,
  305. started_at: new Date('2022-09-15T06:50:00+00:00'),
  306. urls: [
  307. 'https://dev.getsentry.net:7999/replays/',
  308. '/organizations/org-slug/replays/?project=2',
  309. ],
  310. },
  311. {
  312. ...ReplayListFixture()[0],
  313. count_errors: 4,
  314. duration: 400,
  315. finished_at: new Date('2022-09-21T21:40:38+00:00'),
  316. id: REPLAY_ID_2,
  317. started_at: new Date('2022-09-21T21:30:44+00:00'),
  318. urls: [
  319. 'https://dev.getsentry.net:7999/organizations/org-slug/replays/?project=2&statsPeriod=24h',
  320. '/organizations/org-slug/issues/',
  321. '/organizations/org-slug/issues/?project=2',
  322. ],
  323. },
  324. ].map(hydrated => ({
  325. ...hydrated,
  326. started_at: hydrated.started_at.toString(),
  327. finished_at: hydrated.finished_at.toString(),
  328. })),
  329. },
  330. });
  331. // Mock the system date to be 2022-09-28
  332. setMockDate(new Date('Sep 28, 2022 11:29:13 PM UTC'));
  333. render(<GroupReplays />, {
  334. router,
  335. organization,
  336. });
  337. await waitFor(() => {
  338. expect(mockReplayCountApi).toHaveBeenCalled();
  339. });
  340. expect(mockReplayApi).toHaveBeenCalledTimes(1);
  341. // Expect the table to have 2 rows
  342. expect(await screen.findAllByText('testDisplayName')).toHaveLength(2);
  343. const expectedQuery =
  344. 'query=&referrer=%2Forganizations%2F%3AorgId%2Fissues%2F%3AgroupId%2Freplays%2F&statsPeriod=14d&yAxis=count%28%29';
  345. // Expect the first row to have the correct href
  346. expect(screen.getAllByRole('link', {name: 'testDisplayName'})[0]).toHaveAttribute(
  347. 'href',
  348. `/organizations/org-slug/replays/${REPLAY_ID_1}/?${expectedQuery}`
  349. );
  350. // Expect the second row to have the correct href
  351. expect(screen.getAllByRole('link', {name: 'testDisplayName'})[1]).toHaveAttribute(
  352. 'href',
  353. `/organizations/org-slug/replays/${REPLAY_ID_2}/?${expectedQuery}`
  354. );
  355. // Expect the first row to have the correct duration
  356. expect(screen.getByText('14:32:26')).toBeInTheDocument();
  357. // Expect the second row to have the correct duration
  358. expect(screen.getByText('06:40')).toBeInTheDocument();
  359. // Expect the first row to have the correct errors
  360. expect(screen.getAllByTestId('replay-table-count-errors')[0]).toHaveTextContent(
  361. '1'
  362. );
  363. // Expect the second row to have the correct errors
  364. expect(screen.getAllByTestId('replay-table-count-errors')[1]).toHaveTextContent(
  365. '4'
  366. );
  367. // Expect the first row to have the correct date
  368. expect(screen.getByText('14 days ago')).toBeInTheDocument();
  369. // Expect the second row to have the correct date
  370. expect(screen.getByText('7 days ago')).toBeInTheDocument();
  371. });
  372. it('Should render the replay player when replay-play-from-replay-tab is enabled', async () => {
  373. const {router, organization} = init({
  374. organizationProps: {features: ['replay-play-from-replay-tab', 'session-replay']},
  375. });
  376. const mockReplayCountApi = MockApiClient.addMockResponse({
  377. url: mockReplayCountUrl,
  378. body: {
  379. [mockGroup.id]: [REPLAY_ID_1, REPLAY_ID_2],
  380. },
  381. });
  382. MockApiClient.addMockResponse({
  383. url: mockReplayUrl,
  384. statusCode: 200,
  385. body: {
  386. data: [
  387. {
  388. ...ReplayListFixture()[0],
  389. count_errors: 1,
  390. duration: 52346,
  391. finished_at: new Date('2022-09-15T06:54:00+00:00'),
  392. id: REPLAY_ID_1,
  393. started_at: new Date('2022-09-15T06:50:00+00:00'),
  394. urls: [
  395. 'https://dev.getsentry.net:7999/replays/',
  396. '/organizations/org-slug/replays/?project=2',
  397. ],
  398. },
  399. {
  400. ...ReplayListFixture()[0],
  401. count_errors: 4,
  402. duration: 400,
  403. finished_at: new Date('2022-09-21T21:40:38+00:00'),
  404. id: REPLAY_ID_2,
  405. started_at: new Date('2022-09-21T21:30:44+00:00'),
  406. urls: [
  407. 'https://dev.getsentry.net:7999/organizations/org-slug/replays/?project=2&statsPeriod=24h',
  408. '/organizations/org-slug/issues/',
  409. '/organizations/org-slug/issues/?project=2',
  410. ],
  411. },
  412. ].map(hydrated => ({
  413. ...hydrated,
  414. started_at: hydrated.started_at.toString(),
  415. finished_at: hydrated.finished_at.toString(),
  416. })),
  417. },
  418. });
  419. render(<GroupReplays />, {
  420. router,
  421. organization,
  422. });
  423. expect(await screen.findByText('See Full Replay')).toBeInTheDocument();
  424. expect(mockReplayCountApi).toHaveBeenCalledWith(
  425. mockReplayCountUrl,
  426. expect.objectContaining({
  427. query: {
  428. returnIds: true,
  429. data_source: 'discover',
  430. query: `issue.id:[${mockGroup.id}]`,
  431. statsPeriod: '90d',
  432. project: -1,
  433. },
  434. })
  435. );
  436. });
  437. it('Should switch replays when clicking and replay-play-from-replay-tab is enabled', async () => {
  438. const {router, organization} = init({
  439. organizationProps: {features: ['session-replay']},
  440. });
  441. const mockReplayRecord = mockReplay?.getReplay();
  442. const mockReplayCountApi = MockApiClient.addMockResponse({
  443. url: mockReplayCountUrl,
  444. body: {
  445. [mockGroup.id]: [REPLAY_ID_1, REPLAY_ID_2],
  446. },
  447. });
  448. MockApiClient.addMockResponse({
  449. url: mockReplayUrl,
  450. statusCode: 200,
  451. body: {
  452. data: [
  453. {
  454. ...ReplayListFixture()[0],
  455. count_errors: 1,
  456. duration: 52346,
  457. finished_at: new Date('2022-09-15T06:54:00+00:00'),
  458. id: REPLAY_ID_1,
  459. started_at: new Date('2022-09-15T06:50:00+00:00'),
  460. urls: [
  461. 'https://dev.getsentry.net:7999/replays/',
  462. '/organizations/org-slug/replays/?project=2',
  463. ],
  464. },
  465. {
  466. ...ReplayListFixture()[0],
  467. count_errors: 4,
  468. duration: 400,
  469. finished_at: new Date('2022-09-21T21:40:38+00:00'),
  470. id: REPLAY_ID_2,
  471. started_at: new Date('2022-09-21T21:30:44+00:00'),
  472. urls: [
  473. 'https://dev.getsentry.net:7999/organizations/org-slug/replays/?project=2&statsPeriod=24h',
  474. '/organizations/org-slug/issues/',
  475. '/organizations/org-slug/issues/?project=2',
  476. ],
  477. },
  478. ].map(hydrated => ({
  479. ...hydrated,
  480. started_at: hydrated.started_at.toString(),
  481. finished_at: hydrated.finished_at.toString(),
  482. })),
  483. },
  484. });
  485. MockApiClient.addMockResponse({
  486. method: 'POST',
  487. url: `/projects/${organization.slug}/${mockReplayRecord?.project_id}/replays/${mockReplayRecord?.id}/viewed-by/`,
  488. });
  489. render(<GroupReplays />, {
  490. router,
  491. organization,
  492. });
  493. await waitFor(() => {
  494. expect(mockReplayCountApi).toHaveBeenCalledWith(
  495. mockReplayCountUrl,
  496. expect.objectContaining({
  497. query: {
  498. returnIds: true,
  499. data_source: 'discover',
  500. query: `issue.id:[${mockGroup.id}]`,
  501. statsPeriod: '90d',
  502. project: -1,
  503. },
  504. })
  505. );
  506. });
  507. const replayPlayPlause = (
  508. await screen.findAllByTestId('replay-table-play-button')
  509. )[0]!;
  510. await userEvent.click(replayPlayPlause);
  511. await waitFor(() =>
  512. expect(router.replace).toHaveBeenCalledWith(
  513. expect.objectContaining({
  514. pathname: '/organizations/org-slug/replays/',
  515. query: {
  516. selected_replay_index: 1,
  517. },
  518. })
  519. )
  520. );
  521. });
  522. });
  523. });