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 });