groupReplays.spec.tsx 18 KB

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