go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/analysis/frontend/ui/src/components/clusters_table/clusters_table.test.tsx (about)

     1  // Copyright 2022 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  import '@testing-library/jest-dom';
    16  
    17  import dayjs from 'dayjs';
    18  import fetchMock from 'fetch-mock-jest';
    19  import MockDate from 'mockdate';
    20  
    21  import {
    22    fireEvent,
    23    screen,
    24    waitFor,
    25    within,
    26  } from '@testing-library/react';
    27  
    28  import {
    29    ClusterSummaryView,
    30    QueryClusterSummariesRequest,
    31    QueryClusterSummariesResponse,
    32  } from '@/proto/go.chromium.org/luci/analysis/proto/v1/clusters.pb';
    33  import { TimeRange } from '@/proto/go.chromium.org/luci/analysis/proto/v1/common.pb';
    34  import { ProjectMetric } from '@/proto/go.chromium.org/luci/analysis/proto/v1/metrics.pb';
    35  import { renderWithRouterAndClient } from '@/testing_tools/libs/mock_router';
    36  import { mockFetchAuthState } from '@/testing_tools/mocks/authstate_mock';
    37  import {
    38    getMockRuleBasicClusterSummary,
    39    getMockRuleFullClusterSummary,
    40    getMockSuggestedBasicClusterSummary,
    41    getMockSuggestedFullClusterSummary,
    42    mockQueryClusterSummaries,
    43  } from '@/testing_tools/mocks/cluster_mock';
    44  import { mockFetchMetrics } from '@/testing_tools/mocks/metrics_mock';
    45  
    46  import ClustersTable from './clusters_table';
    47  
    48  describe('Test ClustersTable component', () => {
    49    const testNow = '2020-01-08 11:02:03.456+10:00';
    50  
    51    beforeAll(() => {
    52      MockDate.set(testNow);
    53    });
    54    beforeEach(() => {
    55      mockFetchAuthState();
    56    });
    57    afterEach(() => {
    58      fetchMock.mockClear();
    59      fetchMock.reset();
    60    });
    61    afterAll(() => {
    62      MockDate.reset();
    63    });
    64  
    65    const last24Hours: TimeRange = {
    66      earliest: dayjs(testNow).subtract(24, 'hours').toISOString(),
    67      latest: dayjs(testNow).toISOString(),
    68    };
    69  
    70    it('should display column headings reflecting the system metrics', async () => {
    71      const metrics: ProjectMetric[] = [{
    72        name: 'projects/testproject/metrics/metric-a',
    73        metricId: 'metric-a',
    74        humanReadableName: 'Metric Alpha',
    75        description: 'Metric alpha is the first metric',
    76        isDefault: true,
    77        sortPriority: 20,
    78      }, {
    79        name: 'projects/testproject/metrics/metric-b',
    80        metricId: 'metric-b',
    81        humanReadableName: 'Metric Beta',
    82        description: 'Metric beta is the second metric',
    83        isDefault: true,
    84        sortPriority: 30,
    85      }, {
    86        name: 'projects/testproject/metrics/metric-c',
    87        metricId: 'metric-c',
    88        humanReadableName: 'Metric Charlie',
    89        description: 'Metric charlie is the third metric',
    90        isDefault: false,
    91        sortPriority: 10,
    92      }];
    93      mockFetchMetrics('testproject', metrics);
    94  
    95      // Only default metrics (i.e. metric A and B) should be queried and shown.
    96      const request: QueryClusterSummariesRequest = {
    97        project: 'testproject',
    98        timeRange: last24Hours,
    99        orderBy: 'metrics.`metric-b`.value desc',
   100        failureFilter: '',
   101        metrics: ['projects/testproject/metrics/metric-a', 'projects/testproject/metrics/metric-b'],
   102        view: ClusterSummaryView.BASIC,
   103      };
   104      const response: QueryClusterSummariesResponse = { clusterSummaries: [] };
   105      mockQueryClusterSummaries(request, response);
   106  
   107      renderWithRouterAndClient(
   108          <ClustersTable project="testproject" />,
   109      );
   110      await screen.findByTestId('clusters_table_body');
   111      expect(screen.getByText('Metric Alpha')).toBeInTheDocument();
   112      expect(screen.getByText('Metric Beta')).toBeInTheDocument();
   113    });
   114  
   115    it('given clusters, it should display them', async () => {
   116      mockFetchMetrics();
   117  
   118      const mockClusters = [
   119        getMockSuggestedBasicClusterSummary('1234567890abcedf1234567890abcedf'),
   120        getMockRuleBasicClusterSummary('10000000000000001000000000000000'),
   121      ];
   122      const request: QueryClusterSummariesRequest = {
   123        project: 'testproject',
   124        timeRange: last24Hours,
   125        orderBy: 'metrics.`critical-failures-exonerated`.value desc',
   126        failureFilter: '',
   127        metrics: ['projects/testproject/metrics/human-cls-failed-presubmit',
   128          'projects/testproject/metrics/critical-failures-exonerated',
   129          'projects/testproject/metrics/failures'],
   130        view: ClusterSummaryView.BASIC,
   131      };
   132      const response: QueryClusterSummariesResponse = { clusterSummaries: mockClusters };
   133      mockQueryClusterSummaries(request, response);
   134  
   135      renderWithRouterAndClient(
   136          <ClustersTable project="testproject" />,
   137      );
   138  
   139      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
   140      await waitFor(() => expect(screen.getByText(mockClusters[1].bug!.linkText)).toBeInTheDocument());
   141    });
   142  
   143    it('given no clusters, it should display an appropriate message', async () => {
   144      mockFetchMetrics();
   145  
   146      const request: QueryClusterSummariesRequest = {
   147        project: 'testproject',
   148        timeRange: last24Hours,
   149        orderBy: 'metrics.`critical-failures-exonerated`.value desc',
   150        failureFilter: '',
   151        metrics: ['projects/testproject/metrics/human-cls-failed-presubmit',
   152          'projects/testproject/metrics/critical-failures-exonerated',
   153          'projects/testproject/metrics/failures'],
   154        view: ClusterSummaryView.BASIC,
   155      };
   156      const response: QueryClusterSummariesResponse = { clusterSummaries: [] };
   157      mockQueryClusterSummaries(request, response);
   158  
   159      renderWithRouterAndClient(
   160          <ClustersTable project="testproject" />,
   161      );
   162  
   163      await screen.findByTestId('clusters_table_body');
   164  
   165      expect(screen.getByText('Hooray! There are no failures matching the specified criteria.')).toBeInTheDocument();
   166    });
   167  
   168    it('when clicking a sortable column then should modify cluster order', async () => {
   169      mockFetchMetrics();
   170  
   171      const suggestedCluster = getMockSuggestedBasicClusterSummary('1234567890abcedf1234567890abcedf');
   172      const ruleCluster = getMockRuleBasicClusterSummary('10000000000000001000000000000000');
   173      const request: QueryClusterSummariesRequest = {
   174        project: 'testproject',
   175        timeRange: last24Hours,
   176        orderBy: 'metrics.`critical-failures-exonerated`.value desc',
   177        failureFilter: '',
   178        metrics: ['projects/testproject/metrics/human-cls-failed-presubmit',
   179          'projects/testproject/metrics/critical-failures-exonerated',
   180          'projects/testproject/metrics/failures'],
   181        view: ClusterSummaryView.BASIC,
   182      };
   183      const response: QueryClusterSummariesResponse = {
   184        clusterSummaries: [suggestedCluster, ruleCluster],
   185      };
   186      mockQueryClusterSummaries(request, response);
   187  
   188      renderWithRouterAndClient(
   189          <ClustersTable project="testproject" />,
   190      );
   191  
   192      await screen.findByTestId('clusters_table_body');
   193  
   194      // Prepare an updated set of clusters to show after sorting.
   195      const updatedRequest: QueryClusterSummariesRequest = {
   196        project: 'testproject',
   197        timeRange: last24Hours,
   198        orderBy: 'metrics.`failures`.value desc',
   199        failureFilter: '',
   200        metrics: ['projects/testproject/metrics/human-cls-failed-presubmit',
   201          'projects/testproject/metrics/critical-failures-exonerated',
   202          'projects/testproject/metrics/failures'],
   203        view: ClusterSummaryView.BASIC,
   204      };
   205      const ruleCluster2 = getMockRuleBasicClusterSummary('20000000000000002000000000000000');
   206      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
   207      ruleCluster2.bug!.linkText = 'crbug.com/2222222';
   208      const updatedResponse: QueryClusterSummariesResponse = {
   209        clusterSummaries: [suggestedCluster, ruleCluster2],
   210      };
   211      mockQueryClusterSummaries(updatedRequest, updatedResponse);
   212  
   213      fireEvent.click(screen.getByText('Total Failures'));
   214  
   215      await screen.findByText('crbug.com/2222222');
   216      await screen.findByTestId('clusters_table_body');
   217  
   218      expect(screen.getByText('crbug.com/2222222')).toBeInTheDocument();
   219    });
   220  
   221    it('when filtering it should show matching failures', async () => {
   222      mockFetchMetrics();
   223  
   224      const suggestedCluster = getMockSuggestedBasicClusterSummary('1234567890abcedf1234567890abcedf');
   225      const ruleCluster = getMockRuleBasicClusterSummary('10000000000000001000000000000000');
   226      const request: QueryClusterSummariesRequest = {
   227        project: 'testproject',
   228        timeRange: last24Hours,
   229        orderBy: 'metrics.`critical-failures-exonerated`.value desc',
   230        failureFilter: '',
   231        metrics: ['projects/testproject/metrics/human-cls-failed-presubmit',
   232          'projects/testproject/metrics/critical-failures-exonerated',
   233          'projects/testproject/metrics/failures'],
   234        view: ClusterSummaryView.BASIC,
   235      };
   236      const response: QueryClusterSummariesResponse = {
   237        clusterSummaries: [suggestedCluster, ruleCluster],
   238      };
   239      mockQueryClusterSummaries(request, response);
   240  
   241      renderWithRouterAndClient(
   242          <ClustersTable project="testproject" />,
   243      );
   244  
   245      await screen.findByTestId('clusters_table_body');
   246  
   247      // Prepare an updated set of clusters to show after filtering.
   248      const updatedRequest: QueryClusterSummariesRequest = {
   249        project: 'testproject',
   250        timeRange: last24Hours,
   251        orderBy: 'metrics.`critical-failures-exonerated`.value desc',
   252        failureFilter: 'new_criteria',
   253        metrics: ['projects/testproject/metrics/human-cls-failed-presubmit',
   254          'projects/testproject/metrics/critical-failures-exonerated',
   255          'projects/testproject/metrics/failures'],
   256        view: ClusterSummaryView.BASIC,
   257      };
   258      const ruleCluster2 = getMockRuleBasicClusterSummary('20000000000000002000000000000000');
   259      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
   260      ruleCluster2.bug!.linkText = 'crbug.com/3333333';
   261      const updatedResponse: QueryClusterSummariesResponse = {
   262        clusterSummaries: [suggestedCluster, ruleCluster2],
   263      };
   264      mockQueryClusterSummaries(updatedRequest, updatedResponse);
   265  
   266      fireEvent.change(screen.getByTestId('failure_filter_input'), { target: { value: 'new_criteria' } });
   267      fireEvent.blur(screen.getByTestId('failure_filter_input'));
   268  
   269      await waitFor(() => expect(screen.getByText('crbug.com/3333333')).toBeInTheDocument());
   270    });
   271  
   272    it('when changing metrics should hide columns', async () => {
   273      mockFetchMetrics();
   274  
   275      const mockClusters = [
   276        getMockSuggestedBasicClusterSummary('1234567890abcedf1234567890abcedf'),
   277        getMockRuleBasicClusterSummary('10000000000000001000000000000000'),
   278      ];
   279      const request: QueryClusterSummariesRequest = {
   280        project: 'testproject',
   281        timeRange: last24Hours,
   282        orderBy: 'metrics.`critical-failures-exonerated`.value desc',
   283        failureFilter: '',
   284        metrics: ['projects/testproject/metrics/human-cls-failed-presubmit',
   285          'projects/testproject/metrics/critical-failures-exonerated',
   286          'projects/testproject/metrics/failures'],
   287        view: ClusterSummaryView.BASIC,
   288      };
   289      const response: QueryClusterSummariesResponse = { clusterSummaries: mockClusters };
   290      mockQueryClusterSummaries(request, response);
   291  
   292      renderWithRouterAndClient(
   293          <ClustersTable project="testproject" />,
   294      );
   295  
   296      await screen.findByTestId('clusters_table_head');
   297  
   298      await waitFor(() => expect(screen.getByText('User Cls Failed Presubmit')).toBeInTheDocument());
   299  
   300      fireEvent.mouseDown(within(screen.getByTestId('metrics-selection')).getByRole('button'));
   301  
   302      const request2: QueryClusterSummariesRequest = {
   303        project: 'testproject',
   304        timeRange: last24Hours,
   305        failureFilter: '',
   306        orderBy: 'metrics.`critical-failures-exonerated`.value desc',
   307        metrics: ['projects/testproject/metrics/critical-failures-exonerated', 'projects/testproject/metrics/failures'],
   308        view: ClusterSummaryView.BASIC,
   309      };
   310      const response2 = { clusterSummaries: mockClusters };
   311  
   312      mockQueryClusterSummaries(request2, response2);
   313  
   314      const listOfItems = within(screen.getByRole('listbox'));
   315      fireEvent.click(listOfItems.getByText('User Cls Failed Presubmit'));
   316  
   317      await waitFor(() => {
   318        expect(screen.getByTestId('clusters_table_head')).toBeInTheDocument();
   319        expect(within(screen.getByTestId('clusters_table_head')).queryByText('User Cls Failed Presubmit'))
   320            .not.toBeInTheDocument();
   321      });
   322    });
   323  
   324    it('when removing order by column, should select highest sort order in selected metrics', async () => {
   325      const metrics: ProjectMetric[] = [{
   326        name: 'projects/testproject/metrics/metric-a',
   327        metricId: 'metric-a',
   328        humanReadableName: 'Metric Alpha',
   329        description: 'Metric alpha is the first metric',
   330        isDefault: true,
   331        sortPriority: 20,
   332      }, {
   333        name: 'projects/testproject/metrics/metric-b',
   334        metricId: 'metric-b',
   335        humanReadableName: 'Metric Beta',
   336        description: 'Metric beta is the second metric',
   337        isDefault: true,
   338        sortPriority: 10,
   339      }, {
   340        name: 'projects/testproject/metrics/metric-c',
   341        metricId: 'metric-c',
   342        humanReadableName: 'Metric Charlie',
   343        description: 'Metric charlie is the third metric',
   344        isDefault: false,
   345        sortPriority: 30,
   346      }];
   347      mockFetchMetrics('testproject', metrics);
   348  
   349      // Only default metrics (i.e. metric A and B) should be queried and shown.
   350      const request: QueryClusterSummariesRequest = {
   351        project: 'testproject',
   352        timeRange: last24Hours,
   353        orderBy: 'metrics.`metric-a`.value desc',
   354        failureFilter: '',
   355        metrics: ['projects/testproject/metrics/metric-a', 'projects/testproject/metrics/metric-b'],
   356        view: ClusterSummaryView.BASIC,
   357      };
   358      const response: QueryClusterSummariesResponse = { clusterSummaries: [] };
   359      mockQueryClusterSummaries(request, response);
   360  
   361      renderWithRouterAndClient(
   362          <ClustersTable project="testproject" />,
   363      );
   364      await screen.findByTestId('clusters_table_body');
   365      expect(screen.getByText('Metric Alpha')).toBeInTheDocument();
   366      expect(screen.getByText('Metric Beta')).toBeInTheDocument();
   367  
   368      fireEvent.mouseDown(within(screen.getByTestId('metrics-selection')).getByRole('button'));
   369  
   370      const request2: QueryClusterSummariesRequest = {
   371        project: 'testproject',
   372        failureFilter: '',
   373        timeRange: last24Hours,
   374        orderBy: 'metrics.`metric-a`.value desc',
   375        metrics: ['projects/testproject/metrics/metric-a', 'projects/testproject/metrics/metric-b', 'projects/testproject/metrics/metric-c'],
   376        view: ClusterSummaryView.BASIC,
   377      };
   378      const response2 = { clusterSummaries: [] };
   379  
   380      mockQueryClusterSummaries(request2, response2);
   381  
   382      const listOfItems = within(screen.getByRole('listbox'));
   383      fireEvent.click(listOfItems.getByText('Metric Charlie'));
   384      await screen.findByTestId('clusters_table_head');
   385  
   386      const request3: QueryClusterSummariesRequest = {
   387        project: 'testproject',
   388        timeRange: last24Hours,
   389        failureFilter: '',
   390        orderBy: 'metrics.`metric-c`.value desc',
   391        metrics: ['projects/testproject/metrics/metric-b', 'projects/testproject/metrics/metric-c'],
   392        view: ClusterSummaryView.BASIC,
   393      };
   394      const response3 = { clusterSummaries: [] };
   395  
   396      mockQueryClusterSummaries(request3, response3);
   397      fireEvent.click(listOfItems.getByText('Metric Alpha'));
   398      await screen.findByTestId('clusters_table_head');
   399  
   400      await waitFor(() => {
   401        expect(screen.getByTestId('clusters_table_head')).toBeInTheDocument();
   402        expect(within(screen.getByTestId('clusters_table_head')).getByText('Metric Charlie')).toBeInTheDocument();
   403        expect(within(screen.getByTestId('clusters_table_head')).queryByText('Metric Alpha')).not.toBeInTheDocument();
   404      });
   405    });
   406  
   407    it('queries for clusters based on the selected time interval', async () => {
   408      mockFetchMetrics();
   409  
   410      // Default time interval should be last 24 hours.
   411      const request: QueryClusterSummariesRequest = {
   412        project: 'testproject',
   413        timeRange: last24Hours,
   414        failureFilter: '',
   415        orderBy: 'metrics.`critical-failures-exonerated`.value desc',
   416        metrics: [
   417          'projects/testproject/metrics/human-cls-failed-presubmit',
   418          'projects/testproject/metrics/critical-failures-exonerated',
   419          'projects/testproject/metrics/failures',
   420        ],
   421        view: ClusterSummaryView.BASIC,
   422      };
   423      const response: QueryClusterSummariesResponse = { clusterSummaries: [] };
   424      mockQueryClusterSummaries(request, response);
   425  
   426      renderWithRouterAndClient(
   427          <ClustersTable project="testproject" />,
   428      );
   429  
   430      await screen.findByTestId('clusters_table_body');
   431  
   432      expect(screen.getByText('Last 24 hours')).toBeInTheDocument();
   433  
   434      // Mock the parallel calls to QueryClusterSummaries for both
   435      // the basic and full views of cluster summaries for the last week.
   436      const daysInWeek = 7;
   437      const lastWeek: TimeRange = {
   438        earliest: dayjs(testNow).subtract(24 * daysInWeek, 'hours').toISOString(),
   439        latest: dayjs(testNow).toISOString(),
   440      };
   441      const basicSummariesRequest: QueryClusterSummariesRequest = {
   442        project: 'testproject',
   443        timeRange: lastWeek,
   444        failureFilter: '',
   445        orderBy: 'metrics.`critical-failures-exonerated`.value desc',
   446        metrics: [
   447          'projects/testproject/metrics/human-cls-failed-presubmit',
   448          'projects/testproject/metrics/critical-failures-exonerated',
   449          'projects/testproject/metrics/failures',
   450        ],
   451        view: ClusterSummaryView.BASIC,
   452      };
   453      const mockBasicClusterSummaries = [
   454        getMockSuggestedBasicClusterSummary('1234567890abcedf1234567890abcedf'),
   455        getMockRuleBasicClusterSummary('10000000000000001000000000000000'),
   456      ];
   457      const basicSummariesResponse: QueryClusterSummariesResponse = {
   458        clusterSummaries: mockBasicClusterSummaries,
   459      };
   460      const fullSummariesRequest: QueryClusterSummariesRequest = {
   461        project: 'testproject',
   462        timeRange: lastWeek,
   463        failureFilter: '',
   464        orderBy: 'metrics.`critical-failures-exonerated`.value desc',
   465        metrics: ['projects/testproject/metrics/human-cls-failed-presubmit',
   466          'projects/testproject/metrics/critical-failures-exonerated',
   467          'projects/testproject/metrics/failures'],
   468        view: ClusterSummaryView.FULL,
   469      };
   470      const mockFullClusterSummaries = [
   471        getMockSuggestedFullClusterSummary('1234567890abcedf1234567890abcedf'),
   472        getMockRuleFullClusterSummary('10000000000000001000000000000000'),
   473      ];
   474      const fullSummariesResponse: QueryClusterSummariesResponse = {
   475        clusterSummaries: mockFullClusterSummaries,
   476      };
   477      // We need both responses, so these mocks use overwriteRoutes = false.
   478      mockQueryClusterSummaries(basicSummariesRequest, basicSummariesResponse, false);
   479      mockQueryClusterSummaries(fullSummariesRequest, fullSummariesResponse, false);
   480  
   481      // Change interval to the last week.
   482      fireEvent.mouseDown(within(screen.getByTestId('interval-selection')).getByRole('button'));
   483      const options = within(screen.getByRole('listbox'));
   484      fireEvent.click(options.getByText('Last 7 days'));
   485  
   486      await screen.findByTestId('clusters_table_body');
   487  
   488      // Check the time interval has been changed.
   489      expect(screen.queryByText('Last 24 hours')).not.toBeInTheDocument();
   490      expect(screen.getByText('Last 7 days')).toBeInTheDocument();
   491  
   492      // Clusters for the last week should be displayed.
   493      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
   494      expect(screen.getByText(mockFullClusterSummaries[1].bug!.linkText)).toBeInTheDocument();
   495  
   496      // Wait until there are no placeholder sparklines.
   497      await expect(screen.queryAllByTestId('clusters_table_sparkline_skeleton')).toHaveLength(0);
   498  
   499      // Check there are sparklines for each metric for each cluster.
   500      expect(screen.queryAllByTestId('clusters_table_sparkline')).toHaveLength(6);
   501    });
   502  });